pax_global_header00006660000000000000000000000064151722107630014516gustar00rootroot0000000000000052 comment=bd934ea6c827c2d550d1b8638d1554e79334e175 golang-github-git-pkgs-vers-0.2.4/000077500000000000000000000000001517221076300167505ustar00rootroot00000000000000golang-github-git-pkgs-vers-0.2.4/.github/000077500000000000000000000000001517221076300203105ustar00rootroot00000000000000golang-github-git-pkgs-vers-0.2.4/.github/dependabot.yml000066400000000000000000000004051517221076300231370ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: gomod directory: / schedule: interval: weekly open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: / schedule: interval: weekly open-pull-requests-limit: 5 golang-github-git-pkgs-vers-0.2.4/.github/workflows/000077500000000000000000000000001517221076300223455ustar00rootroot00000000000000golang-github-git-pkgs-vers-0.2.4/.github/workflows/ci.yml000066400000000000000000000021641517221076300234660ustar00rootroot00000000000000name: CI on: push: branches: [main] pull_request: branches: [main] permissions: {} jobs: test: runs-on: ubuntu-latest strategy: matrix: go-version: ['1.25'] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: submodules: true persist-credentials: false - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version: ${{ matrix.go-version }} - name: Build run: go build -v ./... - name: Test run: go test -v -race ./... lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: submodules: true persist-credentials: false - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version: '1.25' - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 with: version: latest golang-github-git-pkgs-vers-0.2.4/.gitignore000066400000000000000000000000161517221076300207350ustar00rootroot00000000000000*.test *.prof golang-github-git-pkgs-vers-0.2.4/.gitmodules000066400000000000000000000001601517221076300211220ustar00rootroot00000000000000[submodule "testdata/vers-spec"] path = testdata/vers-spec url = https://github.com/package-url/vers-spec.git golang-github-git-pkgs-vers-0.2.4/LICENSE000066400000000000000000000020571517221076300177610ustar00rootroot00000000000000MIT License Copyright (c) 2026 Andrew Nesbitt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-github-git-pkgs-vers-0.2.4/README.md000066400000000000000000000105071517221076300202320ustar00rootroot00000000000000# vers A Go implementation of the [VERS specification](https://github.com/package-url/purl-spec/blob/main/VERSION-RANGE-SPEC.rst) for version range parsing and comparison across package ecosystems. ## Installation ```bash go get github.com/git-pkgs/vers ``` ## Usage ### Parse VERS URIs The VERS URI format provides a universal way to express version ranges: ```go package main import ( "fmt" "github.com/git-pkgs/vers" ) func main() { // Parse a VERS URI r, _ := vers.Parse("vers:npm/>=1.0.0|<2.0.0") fmt.Println(r.Contains("1.5.0")) // true fmt.Println(r.Contains("2.0.0")) // false fmt.Println(r.Contains("0.9.0")) // false } ``` ### Parse Native Package Manager Syntax Each package ecosystem has its own version constraint syntax. This library parses them all: ```go // npm: caret, tilde, x-ranges, hyphen ranges r, _ := vers.ParseNative("^1.2.3", "npm") r, _ = vers.ParseNative("~1.2.3", "npm") r, _ = vers.ParseNative("1.0.0 - 2.0.0", "npm") r, _ = vers.ParseNative(">=1.0.0 <2.0.0", "npm") // Ruby gems: pessimistic operator r, _ = vers.ParseNative("~> 1.2", "gem") r, _ = vers.ParseNative(">= 1.0, < 2.0", "gem") // Python: compatible release, exclusions r, _ = vers.ParseNative("~=1.4.2", "pypi") r, _ = vers.ParseNative(">=1.0.0,<2.0.0,!=1.5.0", "pypi") // Maven/NuGet: bracket notation r, _ = vers.ParseNative("[1.0,2.0)", "maven") r, _ = vers.ParseNative("[1.0,)", "maven") // Cargo: same syntax as npm r, _ = vers.ParseNative("^1.2.3", "cargo") // Go: comma-separated constraints r, _ = vers.ParseNative(">=1.0.0,<2.0.0", "go") // Debian: >> and << operators r, _ = vers.ParseNative(">> 1.0", "deb") // RPM r, _ = vers.ParseNative(">= 1.0", "rpm") ``` ### Check Version Satisfaction ```go // Using VERS URI (empty scheme) ok, _ := vers.Satisfies("1.5.0", "vers:npm/>=1.0.0|<2.0.0", "") fmt.Println(ok) // true // Using native syntax ok, _ = vers.Satisfies("1.5.0", "^1.0.0", "npm") fmt.Println(ok) // true ``` ### Compare Versions ```go vers.Compare("1.2.3", "1.2.4") // -1 (a < b) vers.Compare("2.0.0", "1.9.9") // 1 (a > b) vers.Compare("1.0.0", "1.0.0") // 0 (equal) // Prerelease versions sort before stable vers.Compare("1.0.0", "1.0.0-alpha") // 1 (stable > prerelease) ``` ### Version Validation and Normalization ```go vers.Valid("1.2.3") // true vers.Valid("invalid") // false v, _ := vers.Normalize("1") // "1.0.0" v, _ = vers.Normalize("1.2") // "1.2.0" v, _ = vers.Normalize("1.2.3") // "1.2.3" ``` ### Create Ranges Programmatically ```go // Exact version r := vers.Exact("1.2.3") // Greater/less than r = vers.GreaterThan("1.0.0", true) // >=1.0.0 r = vers.GreaterThan("1.0.0", false) // >1.0.0 r = vers.LessThan("2.0.0", false) // <2.0.0 // Unbounded (matches all) r = vers.Unbounded() // Combine ranges r1, _ := vers.ParseNative(">=1.0.0", "npm") r2, _ := vers.ParseNative("<2.0.0", "npm") intersection := r1.Intersect(r2) // >=1.0.0 AND <2.0.0 union := r1.Union(r2) // >=1.0.0 OR <2.0.0 // Add exclusions r = r.Exclude("1.5.0") ``` ### Convert Back to VERS URI ```go r, _ := vers.ParseNative("^1.2.3", "npm") uri := vers.ToVersString(r, "npm") // vers:npm/>=1.2.3|<2.0.0 // Unbounded range r = vers.Unbounded() uri = vers.ToVersString(r, "npm") // vers:npm/* ``` ## Supported Ecosystems | Ecosystem | Scheme | Example Syntax | |-----------|--------|----------------| | npm | `npm` | `^1.2.3`, `~1.2.3`, `>=1.0.0 <2.0.0`, `1.x`, `1.0.0 - 2.0.0` | | RubyGems | `gem`, `rubygems` | `~> 1.2`, `>= 1.0, < 2.0` | | PyPI | `pypi` | `~=1.4.2`, `>=1.0.0,<2.0.0`, `!=1.5.0` | | Maven | `maven` | `[1.0,2.0)`, `(1.0,2.0]`, `[1.0,)`, `[1.0]` | | NuGet | `nuget` | Same as Maven | | Cargo | `cargo` | Same as npm | | Go | `go`, `golang` | `>=1.0.0,<2.0.0` | | Debian | `deb`, `debian` | `>> 1.0`, `<< 2.0`, `>= 1.0` | | RPM | `rpm` | `>= 1.0`, `<= 2.0` | ## Development ### Run Tests ```bash go test ./... ``` ### Run Tests with Verbose Output ```bash go test -v ./... ``` ### Run Specific Tests ```bash go test -v -run TestParseNpmRange ``` ### Check Test Coverage ```bash go test -cover ./... ``` ### Generate Coverage Report ```bash go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out ``` ### Run Benchmarks ```bash go test -bench=. -benchmem ``` Run specific benchmarks: ```bash go test -bench=BenchmarkContains -benchmem ``` ## License MIT golang-github-git-pkgs-vers-0.2.4/bench_test.go000066400000000000000000000063141517221076300214210ustar00rootroot00000000000000package vers import "testing" // Parsing benchmarks func BenchmarkParse_VersURI_Simple(b *testing.B) { for b.Loop() { _, _ = Parse("vers:npm/>=1.2.3") } } func BenchmarkParse_VersURI_Complex(b *testing.B) { for b.Loop() { _, _ = Parse("vers:npm/>=1.2.3|<2.0.0|!=1.5.0") } } func BenchmarkParseNative_Npm_Caret(b *testing.B) { for b.Loop() { _, _ = ParseNative("^1.2.3", "npm") } } func BenchmarkParseNative_Npm_Tilde(b *testing.B) { for b.Loop() { _, _ = ParseNative("~1.2.3", "npm") } } func BenchmarkParseNative_Npm_Range(b *testing.B) { for b.Loop() { _, _ = ParseNative(">=1.0.0 <2.0.0", "npm") } } func BenchmarkParseNative_Npm_Or(b *testing.B) { for b.Loop() { _, _ = ParseNative("^1.0.0 || ^2.0.0 || ^3.0.0", "npm") } } func BenchmarkParseNative_Gem_Pessimistic(b *testing.B) { for b.Loop() { _, _ = ParseNative("~> 1.2.3", "gem") } } func BenchmarkParseNative_Pypi_Compatible(b *testing.B) { for b.Loop() { _, _ = ParseNative("~=1.4.2", "pypi") } } func BenchmarkParseNative_Maven_Bracket(b *testing.B) { for b.Loop() { _, _ = ParseNative("[1.0,2.0)", "maven") } } // Contains benchmarks func BenchmarkContains_Simple(b *testing.B) { r, _ := ParseNative("^1.2.3", "npm") b.ResetTimer() for b.Loop() { r.Contains("1.5.0") } } func BenchmarkContains_MultiInterval(b *testing.B) { r, _ := ParseNative("^1.0.0 || ^2.0.0 || ^3.0.0", "npm") b.ResetTimer() for b.Loop() { r.Contains("2.5.0") } } func BenchmarkContains_WithExclusions(b *testing.B) { r, _ := Parse("vers:npm/>=1.0.0|<2.0.0|!=1.5.0|!=1.6.0|!=1.7.0") b.ResetTimer() for b.Loop() { r.Contains("1.8.0") } } func BenchmarkContains_Prerelease(b *testing.B) { r, _ := ParseNative(">=1.0.0-alpha.1", "npm") b.ResetTimer() for b.Loop() { r.Contains("1.0.0-beta.2") } } func BenchmarkCompare_Simple(b *testing.B) { for b.Loop() { Compare("1.2.3", "1.2.4") } } func BenchmarkCompare_Prerelease(b *testing.B) { for b.Loop() { Compare("1.0.0-alpha.1", "1.0.0-beta.2") } } // Range operation benchmarks func BenchmarkUnion_TwoRanges(b *testing.B) { r1, _ := ParseNative("^1.0.0", "npm") r2, _ := ParseNative("^2.0.0", "npm") b.ResetTimer() for b.Loop() { r1.Union(r2) } } func BenchmarkUnion_ManyRanges(b *testing.B) { ranges := make([]*Range, 10) for i := range ranges { ranges[i], _ = ParseNative("^1.0.0", "npm") } b.ResetTimer() for b.Loop() { result := ranges[0] for _, r := range ranges[1:] { result = result.Union(r) } } } func BenchmarkIntersect_TwoRanges(b *testing.B) { r1, _ := ParseNative(">=1.0.0", "npm") r2, _ := ParseNative("<2.0.0", "npm") b.ResetTimer() for b.Loop() { r1.Intersect(r2) } } func BenchmarkIntersect_ManyRanges(b *testing.B) { r1, _ := ParseNative(">=1.0.0", "npm") r2, _ := ParseNative("<3.0.0", "npm") r3, _ := ParseNative(">=1.5.0", "npm") r4, _ := ParseNative("<2.5.0", "npm") b.ResetTimer() for b.Loop() { r1.Intersect(r2).Intersect(r3).Intersect(r4) } } // Satisfies benchmarks (combines parsing and contains) func BenchmarkSatisfies_VersURI(b *testing.B) { for b.Loop() { _, _ = Satisfies("1.5.0", "vers:npm/>=1.0.0|<2.0.0", "") } } func BenchmarkSatisfies_Native(b *testing.B) { for b.Loop() { _, _ = Satisfies("1.5.0", "^1.2.3", "npm") } } golang-github-git-pkgs-vers-0.2.4/conformance_test.go000066400000000000000000000132141517221076300226310ustar00rootroot00000000000000package vers import ( "encoding/json" "os" "path/filepath" "testing" ) type versTestFile struct { Tests []versTestCase `json:"tests"` } type versTestCase struct { Description string `json:"description"` TestGroup string `json:"test_group"` TestType string `json:"test_type"` Input json.RawMessage `json:"input"` ExpectedOutput json.RawMessage `json:"expected_output"` } type fromNativeInput struct { NativeRange string `json:"native_range"` Scheme string `json:"scheme"` } type containmentInput struct { Vers string `json:"vers"` Version string `json:"version"` } type versionCmpInput struct { InputScheme string `json:"input_scheme"` Versions []string `json:"versions"` } func loadTestFile(t *testing.T, filename string) *versTestFile { t.Helper() path := filepath.Join("testdata", "vers-spec", "tests", filename) data, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read test file %s: %v", filename, err) } var tf versTestFile if err := json.Unmarshal(data, &tf); err != nil { t.Fatalf("failed to parse test file %s: %v", filename, err) } return &tf } func TestConformance_FromNative(t *testing.T) { files := []string{ "gem_range_from_native_test.json", "npm_range_from_native_test.json", "pypi_range_from_native_test.json", "nuget_range_from_native_test.json", } for _, file := range files { t.Run(file, func(t *testing.T) { tf := loadTestFile(t, file) for _, tc := range tf.Tests { if tc.TestType != "from_native" { continue } var input fromNativeInput if err := json.Unmarshal(tc.Input, &input); err != nil { t.Errorf("failed to parse input: %v", err) continue } var expected string if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil { t.Errorf("failed to parse expected output: %v", err) continue } t.Run(input.NativeRange, func(t *testing.T) { r, err := ParseNative(input.NativeRange, input.Scheme) if err != nil { t.Errorf("ParseNative(%q, %q) error: %v", input.NativeRange, input.Scheme, err) return } got := ToVersString(r, input.Scheme) if got != expected { t.Errorf("ParseNative(%q, %q) = %q, want %q", input.NativeRange, input.Scheme, got, expected) } }) } }) } } func TestConformance_Containment(t *testing.T) { files := []string{ "npm_range_containment_test.json", "pypi_range_containment_test.json", } for _, file := range files { t.Run(file, func(t *testing.T) { tf := loadTestFile(t, file) for _, tc := range tf.Tests { if tc.TestType != "containment" { continue } var input containmentInput if err := json.Unmarshal(tc.Input, &input); err != nil { t.Errorf("failed to parse input: %v", err) continue } var expected bool if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil { t.Errorf("failed to parse expected output: %v", err) continue } t.Run(input.Vers+"_"+input.Version, func(t *testing.T) { r, err := Parse(input.Vers) if err != nil { t.Errorf("Parse(%q) error: %v", input.Vers, err) return } got := r.Contains(input.Version) if got != expected { t.Errorf("Parse(%q).Contains(%q) = %v, want %v", input.Vers, input.Version, got, expected) } }) } }) } } //nolint:gocognit func TestConformance_VersionComparison(t *testing.T) { files := []string{ "nuget_version_cmp_test.json", "maven_version_cmp_test.json", } for _, file := range files { t.Run(file, func(t *testing.T) { tf := loadTestFile(t, file) for _, tc := range tf.Tests { var input versionCmpInput if err := json.Unmarshal(tc.Input, &input); err != nil { t.Errorf("failed to parse input: %v", err) continue } if len(input.Versions) != 2 { t.Errorf("expected 2 versions, got %d", len(input.Versions)) continue } v1, v2 := input.Versions[0], input.Versions[1] switch tc.TestType { case "equality": var expected bool if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil { t.Errorf("failed to parse expected output: %v", err) continue } t.Run("eq_"+v1+"_"+v2, func(t *testing.T) { cmp := CompareWithScheme(v1, v2, input.InputScheme) got := cmp == 0 if got != expected { t.Errorf("CompareWithScheme(%q, %q, %q) == 0 is %v, want %v (cmp=%d)", v1, v2, input.InputScheme, got, expected, cmp) } }) case "comparison": var expected []string if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil { t.Errorf("failed to parse expected output: %v", err) continue } if len(expected) != 2 { t.Errorf("expected 2 versions in output, got %d", len(expected)) continue } t.Run("cmp_"+v1+"_"+v2, func(t *testing.T) { cmp := CompareWithScheme(v1, v2, input.InputScheme) v1MatchesFirst := CompareWithScheme(v1, expected[0], input.InputScheme) == 0 versionsEqual := expected[0] == expected[1] || CompareWithScheme(expected[0], expected[1], input.InputScheme) == 0 switch { case versionsEqual: if cmp != 0 { t.Errorf("CompareWithScheme(%q, %q, %q) = %d, want 0 (equal versions)", v1, v2, input.InputScheme, cmp) } case v1MatchesFirst: if cmp >= 0 { t.Errorf("CompareWithScheme(%q, %q, %q) = %d, want < 0 (expected order: %v)", v1, v2, input.InputScheme, cmp, expected) } default: if cmp <= 0 { t.Errorf("CompareWithScheme(%q, %q, %q) = %d, want > 0 (expected order: %v)", v1, v2, input.InputScheme, cmp, expected) } } }) } } }) } } golang-github-git-pkgs-vers-0.2.4/constraint.go000066400000000000000000000055301517221076300214660ustar00rootroot00000000000000package vers import ( "fmt" "regexp" "strings" ) // Valid constraint operators. var ValidOperators = []string{"=", "!=", "<", "<=", ">", ">="} var operatorRegex = regexp.MustCompile(`^(!=|>=|<=|[<>=])`) // Constraint represents a single version constraint (e.g., ">=1.2.3"). type Constraint struct { Operator string Version string } // ParseConstraint parses a constraint string into a Constraint. func ParseConstraint(s string) (*Constraint, error) { return parseConstraintWithScheme(s, "") } // parseConstraintWithScheme parses a constraint with scheme-specific handling. // For Go/golang schemes, the v prefix is preserved. func parseConstraintWithScheme(s, scheme string) (*Constraint, error) { s = strings.TrimSpace(s) if s == "" { return nil, fmt.Errorf("empty constraint") } // Go versions preserve the v prefix preserveVPrefix := scheme == "go" || scheme == "golang" matches := operatorRegex.FindStringSubmatch(s) if matches != nil { operator := matches[1] version := strings.TrimSpace(s[len(operator):]) if version == "" { return nil, fmt.Errorf("invalid constraint format: %s", s) } if !preserveVPrefix { version = stripVPrefix(version) } return &Constraint{Operator: operator, Version: version}, nil } // No operator found, treat as exact match version := s if !preserveVPrefix { version = stripVPrefix(s) } return &Constraint{Operator: "=", Version: version}, nil } // stripVPrefix removes a leading 'v' or 'V' from version strings. func stripVPrefix(version string) string { if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') { return version[1:] } return version } // ToInterval converts this constraint to an interval. // Returns nil for exclusion constraints (!=). func (c *Constraint) ToInterval() (Interval, bool) { switch c.Operator { case "=": return ExactInterval(c.Version), true case "!=": // Exclusions need special handling in ranges return Interval{}, false case ">": return GreaterThanInterval(c.Version, false), true case ">=": return GreaterThanInterval(c.Version, true), true case "<": return LessThanInterval(c.Version, false), true case "<=": return LessThanInterval(c.Version, true), true default: return Interval{}, false } } // IsExclusion returns true if this is an exclusion constraint (!=). func (c *Constraint) IsExclusion() bool { return c.Operator == "!=" } // Satisfies checks if a version satisfies this constraint. func (c *Constraint) Satisfies(version string) bool { cmp := CompareVersions(version, c.Version) switch c.Operator { case "=": return cmp == 0 case "!=": return cmp != 0 case ">": return cmp > 0 case ">=": return cmp >= 0 case "<": return cmp < 0 case "<=": return cmp <= 0 default: return false } } // String returns the constraint as a string. func (c *Constraint) String() string { return c.Operator + c.Version } golang-github-git-pkgs-vers-0.2.4/constraint_test.go000066400000000000000000000040601517221076300225220ustar00rootroot00000000000000package vers import "testing" func TestParseConstraint(t *testing.T) { tests := []struct { input string operator string version string wantErr bool }{ {">=1.0.0", ">=", "1.0.0", false}, {"<=2.0.0", "<=", "2.0.0", false}, {">1.0.0", ">", "1.0.0", false}, {"<2.0.0", "<", "2.0.0", false}, {"=1.0.0", "=", "1.0.0", false}, {"!=1.5.0", "!=", "1.5.0", false}, {"1.0.0", "=", "1.0.0", false}, // No operator defaults to = {"", "", "", true}, {">=", "", "", true}, // Missing version } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { c, err := ParseConstraint(tt.input) if (err != nil) != tt.wantErr { t.Errorf("ParseConstraint(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) return } if err != nil { return } if c.Operator != tt.operator { t.Errorf("Operator = %q, want %q", c.Operator, tt.operator) } if c.Version != tt.version { t.Errorf("Version = %q, want %q", c.Version, tt.version) } }) } } func TestConstraintSatisfies(t *testing.T) { tests := []struct { constraint string version string want bool }{ {">=1.0.0", "1.0.0", true}, {">=1.0.0", "1.5.0", true}, {">=1.0.0", "0.9.0", false}, {">1.0.0", "1.0.0", false}, {">1.0.0", "1.0.1", true}, {"<=2.0.0", "2.0.0", true}, {"<=2.0.0", "1.5.0", true}, {"<=2.0.0", "2.0.1", false}, {"<2.0.0", "2.0.0", false}, {"<2.0.0", "1.9.9", true}, {"=1.0.0", "1.0.0", true}, {"=1.0.0", "1.0.1", false}, {"!=1.5.0", "1.5.0", false}, {"!=1.5.0", "1.4.0", true}, } for _, tt := range tests { t.Run(tt.constraint+"_"+tt.version, func(t *testing.T) { c, err := ParseConstraint(tt.constraint) if err != nil { t.Fatalf("ParseConstraint(%q) error = %v", tt.constraint, err) } got := c.Satisfies(tt.version) if got != tt.want { t.Errorf("Satisfies(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestConstraintString(t *testing.T) { c, _ := ParseConstraint(">=1.0.0") if c.String() != ">=1.0.0" { t.Errorf("String() = %q, want %q", c.String(), ">=1.0.0") } } golang-github-git-pkgs-vers-0.2.4/go.mod000066400000000000000000000000531517221076300200540ustar00rootroot00000000000000module github.com/git-pkgs/vers go 1.25.6 golang-github-git-pkgs-vers-0.2.4/interval.go000066400000000000000000000144041517221076300211260ustar00rootroot00000000000000package vers import "fmt" // Interval represents a mathematical interval of versions. // For example, [1.0.0, 2.0.0) represents versions from 1.0.0 (inclusive) to 2.0.0 (exclusive). type Interval struct { Min string Max string MinInclusive bool MaxInclusive bool } // NewInterval creates a new interval with the given bounds. func NewInterval(min, max string, minInclusive, maxInclusive bool) Interval { return Interval{ Min: min, Max: max, MinInclusive: minInclusive, MaxInclusive: maxInclusive, } } // EmptyInterval creates an interval that matches no versions. func EmptyInterval() Interval { return Interval{Min: "1", Max: "0", MinInclusive: true, MaxInclusive: true} } // UnboundedInterval creates an interval that matches all versions. func UnboundedInterval() Interval { return Interval{} } // ExactInterval creates an interval that matches exactly one version. func ExactInterval(version string) Interval { return Interval{Min: version, Max: version, MinInclusive: true, MaxInclusive: true} } // GreaterThanInterval creates an interval for versions greater than the given version. func GreaterThanInterval(version string, inclusive bool) Interval { return Interval{Min: version, MinInclusive: inclusive} } // LessThanInterval creates an interval for versions less than the given version. func LessThanInterval(version string, inclusive bool) Interval { return Interval{Max: version, MaxInclusive: inclusive} } // IsEmpty returns true if this interval matches no versions. func (i Interval) IsEmpty() bool { if i.Min != "" && i.Max != "" { cmp := CompareVersions(i.Min, i.Max) if cmp > 0 { return true } if cmp == 0 && (!i.MinInclusive || !i.MaxInclusive) { return true } } return false } // IsUnbounded returns true if this interval matches all versions. func (i Interval) IsUnbounded() bool { return i.Min == "" && i.Max == "" } // Contains checks if the interval contains the given version. func (i Interval) Contains(version string) bool { if i.IsEmpty() { return false } if i.IsUnbounded() { return true } // Check minimum bound if i.Min != "" { cmp := CompareVersions(version, i.Min) if i.MinInclusive { if cmp < 0 { return false } } else { if cmp <= 0 { return false } } } // Check maximum bound if i.Max != "" { cmp := CompareVersions(version, i.Max) if i.MaxInclusive { if cmp > 0 { return false } } else { if cmp >= 0 { return false } } } return true } // Intersect returns the intersection of two intervals. func (i Interval) Intersect(other Interval) Interval { if i.IsEmpty() || other.IsEmpty() { return EmptyInterval() } result := Interval{} // Determine new minimum switch { case i.Min != "" && other.Min != "": cmp := CompareVersions(i.Min, other.Min) switch { case cmp > 0: result.Min = i.Min result.MinInclusive = i.MinInclusive case cmp < 0: result.Min = other.Min result.MinInclusive = other.MinInclusive default: result.Min = i.Min result.MinInclusive = i.MinInclusive && other.MinInclusive } case i.Min != "": result.Min = i.Min result.MinInclusive = i.MinInclusive case other.Min != "": result.Min = other.Min result.MinInclusive = other.MinInclusive } // Determine new maximum switch { case i.Max != "" && other.Max != "": cmp := CompareVersions(i.Max, other.Max) switch { case cmp < 0: result.Max = i.Max result.MaxInclusive = i.MaxInclusive case cmp > 0: result.Max = other.Max result.MaxInclusive = other.MaxInclusive default: result.Max = i.Max result.MaxInclusive = i.MaxInclusive && other.MaxInclusive } case i.Max != "": result.Max = i.Max result.MaxInclusive = i.MaxInclusive case other.Max != "": result.Max = other.Max result.MaxInclusive = other.MaxInclusive } return result } // Overlaps returns true if the two intervals overlap. func (i Interval) Overlaps(other Interval) bool { if i.IsEmpty() || other.IsEmpty() { return false } return !i.Intersect(other).IsEmpty() } // Adjacent returns true if the two intervals are adjacent (can be merged). func (i Interval) Adjacent(other Interval) bool { if i.IsEmpty() || other.IsEmpty() { return false } if i.Max != "" && other.Min != "" && CompareVersions(i.Max, other.Min) == 0 { return (i.MaxInclusive && !other.MinInclusive) || (!i.MaxInclusive && other.MinInclusive) } if i.Min != "" && other.Max != "" && CompareVersions(i.Min, other.Max) == 0 { return (i.MinInclusive && !other.MaxInclusive) || (!i.MinInclusive && other.MaxInclusive) } return false } // Union returns the union of two intervals, or nil if they cannot be merged. func (i Interval) Union(other Interval) *Interval { if i.IsEmpty() { return &other } if other.IsEmpty() { return &i } if !i.Overlaps(other) && !i.Adjacent(other) { return nil } result := Interval{} // Determine new minimum (take the smaller one, "" means unbounded) if i.Min == "" || other.Min == "" { result.Min = "" result.MinInclusive = false } else { cmp := CompareVersions(i.Min, other.Min) switch { case cmp < 0: result.Min = i.Min result.MinInclusive = i.MinInclusive case cmp > 0: result.Min = other.Min result.MinInclusive = other.MinInclusive default: result.Min = i.Min result.MinInclusive = i.MinInclusive || other.MinInclusive } } // Determine new maximum (take the larger one, "" means unbounded) if i.Max == "" || other.Max == "" { result.Max = "" result.MaxInclusive = false } else { cmp := CompareVersions(i.Max, other.Max) switch { case cmp > 0: result.Max = i.Max result.MaxInclusive = i.MaxInclusive case cmp < 0: result.Max = other.Max result.MaxInclusive = other.MaxInclusive default: result.Max = i.Max result.MaxInclusive = i.MaxInclusive || other.MaxInclusive } } return &result } // String returns a string representation of the interval. func (i Interval) String() string { if i.IsEmpty() { return "empty" } if i.IsUnbounded() { return "(-inf,+inf)" } minBracket := "(" if i.MinInclusive { minBracket = "[" } maxBracket := ")" if i.MaxInclusive { maxBracket = "]" } minStr := "-inf" if i.Min != "" { minStr = i.Min } maxStr := "+inf" if i.Max != "" { maxStr = i.Max } return fmt.Sprintf("%s%s,%s%s", minBracket, minStr, maxStr, maxBracket) } golang-github-git-pkgs-vers-0.2.4/interval_test.go000066400000000000000000000255351517221076300221740ustar00rootroot00000000000000package vers import "testing" func TestNewInterval(t *testing.T) { i := NewInterval("1.0.0", "2.0.0", true, false) if i.Min != "1.0.0" { //nolint:goconst t.Errorf("Min = %q, want %q", i.Min, "1.0.0") } if i.Max != "2.0.0" { //nolint:goconst t.Errorf("Max = %q, want %q", i.Max, "2.0.0") } if !i.MinInclusive { t.Error("MinInclusive = false, want true") } if i.MaxInclusive { t.Error("MaxInclusive = true, want false") } } func TestExactInterval(t *testing.T) { i := ExactInterval("1.2.3") if i.Min != "1.2.3" || i.Max != "1.2.3" { t.Errorf("ExactInterval bounds incorrect: [%s, %s]", i.Min, i.Max) } if !i.MinInclusive || !i.MaxInclusive { t.Error("ExactInterval should be inclusive on both ends") } if !i.Contains("1.2.3") { t.Error("ExactInterval should contain its version") } if i.Contains("1.2.4") { t.Error("ExactInterval should not contain other versions") } } func TestGreaterThanInterval(t *testing.T) { tests := []struct { name string version string inclusive bool check string want bool }{ {">1.0.0 contains 1.0.1", "1.0.0", false, "1.0.1", true}, {">1.0.0 excludes 1.0.0", "1.0.0", false, "1.0.0", false}, {">1.0.0 excludes 0.9.9", "1.0.0", false, "0.9.9", false}, {">=1.0.0 contains 1.0.0", "1.0.0", true, "1.0.0", true}, {">=1.0.0 contains 1.0.1", "1.0.0", true, "1.0.1", true}, {">=1.0.0 excludes 0.9.9", "1.0.0", true, "0.9.9", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { i := GreaterThanInterval(tt.version, tt.inclusive) got := i.Contains(tt.check) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.check, got, tt.want) } }) } } func TestLessThanInterval(t *testing.T) { tests := []struct { name string version string inclusive bool check string want bool }{ {"<2.0.0 contains 1.9.9", "2.0.0", false, "1.9.9", true}, {"<2.0.0 excludes 2.0.0", "2.0.0", false, "2.0.0", false}, {"<2.0.0 excludes 2.0.1", "2.0.0", false, "2.0.1", false}, {"<=2.0.0 contains 2.0.0", "2.0.0", true, "2.0.0", true}, {"<=2.0.0 contains 1.9.9", "2.0.0", true, "1.9.9", true}, {"<=2.0.0 excludes 2.0.1", "2.0.0", true, "2.0.1", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { i := LessThanInterval(tt.version, tt.inclusive) got := i.Contains(tt.check) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.check, got, tt.want) } }) } } func TestIntervalIsEmpty(t *testing.T) { tests := []struct { name string i Interval want bool }{ {"empty interval", EmptyInterval(), true}, {"min > max", NewInterval("2.0.0", "1.0.0", true, true), true}, {"equal exclusive min", NewInterval("1.0.0", "1.0.0", false, true), true}, {"equal exclusive max", NewInterval("1.0.0", "1.0.0", true, false), true}, {"equal exclusive both", NewInterval("1.0.0", "1.0.0", false, false), true}, {"equal inclusive", NewInterval("1.0.0", "1.0.0", true, true), false}, {"valid range", NewInterval("1.0.0", "2.0.0", true, false), false}, {"unbounded", UnboundedInterval(), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.i.IsEmpty() if got != tt.want { t.Errorf("IsEmpty() = %v, want %v", got, tt.want) } }) } } func TestIntervalIsUnbounded(t *testing.T) { tests := []struct { name string i Interval want bool }{ {"unbounded", UnboundedInterval(), true}, {"has min", GreaterThanInterval("1.0.0", true), false}, {"has max", LessThanInterval("2.0.0", true), false}, {"has both", NewInterval("1.0.0", "2.0.0", true, true), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.i.IsUnbounded() if got != tt.want { t.Errorf("IsUnbounded() = %v, want %v", got, tt.want) } }) } } func TestIntervalContains(t *testing.T) { tests := []struct { name string i Interval version string want bool }{ {"[1.0.0,2.0.0] contains 1.0.0", NewInterval("1.0.0", "2.0.0", true, true), "1.0.0", true}, {"[1.0.0,2.0.0] contains 1.5.0", NewInterval("1.0.0", "2.0.0", true, true), "1.5.0", true}, {"[1.0.0,2.0.0] contains 2.0.0", NewInterval("1.0.0", "2.0.0", true, true), "2.0.0", true}, {"[1.0.0,2.0.0] excludes 0.9.0", NewInterval("1.0.0", "2.0.0", true, true), "0.9.0", false}, {"[1.0.0,2.0.0] excludes 2.0.1", NewInterval("1.0.0", "2.0.0", true, true), "2.0.1", false}, {"[1.0.0,2.0.0) excludes 2.0.0", NewInterval("1.0.0", "2.0.0", true, false), "2.0.0", false}, {"(1.0.0,2.0.0] excludes 1.0.0", NewInterval("1.0.0", "2.0.0", false, true), "1.0.0", false}, {"unbounded contains anything", UnboundedInterval(), "999.999.999", true}, {"empty contains nothing", EmptyInterval(), "1.0.0", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.i.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestIntervalIntersect(t *testing.T) { tests := []struct { name string i1 Interval i2 Interval expect func(Interval) bool }{ { "overlapping ranges", NewInterval("1.0.0", "3.0.0", true, true), NewInterval("2.0.0", "4.0.0", true, true), func(r Interval) bool { return r.Min == "2.0.0" && r.Max == "3.0.0" && r.MinInclusive && r.MaxInclusive //nolint:goconst }, }, { "one inside other", NewInterval("1.0.0", "5.0.0", true, true), NewInterval("2.0.0", "3.0.0", true, true), func(r Interval) bool { return r.Min == "2.0.0" && r.Max == "3.0.0" }, }, { "same boundary different inclusivity", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("1.0.0", "2.0.0", false, false), func(r Interval) bool { return r.Min == "1.0.0" && r.Max == "2.0.0" && !r.MinInclusive && !r.MaxInclusive }, }, { "non-overlapping", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("3.0.0", "4.0.0", true, true), func(r Interval) bool { return r.IsEmpty() }, }, { "unbounded with bounded", UnboundedInterval(), NewInterval("1.0.0", "2.0.0", true, false), func(r Interval) bool { return r.Min == "1.0.0" && r.Max == "2.0.0" && r.MinInclusive && !r.MaxInclusive }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.i1.Intersect(tt.i2) if !tt.expect(result) { t.Errorf("Intersect() = %v, didn't meet expectations", result) } }) } } func TestIntervalOverlaps(t *testing.T) { tests := []struct { name string i1 Interval i2 Interval want bool }{ {"overlapping", NewInterval("1.0.0", "3.0.0", true, true), NewInterval("2.0.0", "4.0.0", true, true), true}, {"touching inclusive", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("2.0.0", "3.0.0", true, true), true}, {"touching exclusive", NewInterval("1.0.0", "2.0.0", true, false), NewInterval("2.0.0", "3.0.0", false, true), false}, {"non-overlapping", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("3.0.0", "4.0.0", true, true), false}, {"empty", EmptyInterval(), NewInterval("1.0.0", "2.0.0", true, true), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.i1.Overlaps(tt.i2) if got != tt.want { t.Errorf("Overlaps() = %v, want %v", got, tt.want) } }) } } func TestIntervalAdjacent(t *testing.T) { tests := []struct { name string i1 Interval i2 Interval want bool }{ {"adjacent [,a] (a,]", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("2.0.0", "3.0.0", false, true), true}, {"adjacent [,a) [a,]", NewInterval("1.0.0", "2.0.0", true, false), NewInterval("2.0.0", "3.0.0", true, true), true}, {"not adjacent [,a] [a,]", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("2.0.0", "3.0.0", true, true), false}, {"gap between", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("3.0.0", "4.0.0", true, true), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.i1.Adjacent(tt.i2) if got != tt.want { t.Errorf("Adjacent() = %v, want %v", got, tt.want) } }) } } func TestIntervalUnion(t *testing.T) { tests := []struct { name string i1 Interval i2 Interval isNil bool expect func(*Interval) bool }{ { "overlapping", NewInterval("1.0.0", "3.0.0", true, true), NewInterval("2.0.0", "4.0.0", true, true), false, func(r *Interval) bool { return r.Min == "1.0.0" && r.Max == "4.0.0" && r.MinInclusive && r.MaxInclusive }, }, { "adjacent", NewInterval("1.0.0", "2.0.0", true, false), NewInterval("2.0.0", "3.0.0", true, true), false, func(r *Interval) bool { return r.Min == "1.0.0" && r.Max == "3.0.0" }, }, { "non-overlapping non-adjacent", NewInterval("1.0.0", "2.0.0", true, true), NewInterval("3.0.0", "4.0.0", true, true), true, nil, }, { "same boundary different inclusivity", NewInterval("1.0.0", "2.0.0", true, false), NewInterval("1.0.0", "2.0.0", false, true), false, func(r *Interval) bool { return r.MinInclusive && r.MaxInclusive }, }, { "unbounded min with bounded", LessThanInterval("2.0.0", true), NewInterval("1.0.0", "3.0.0", true, true), false, func(r *Interval) bool { return r.Min == "" && r.Max == "3.0.0" && r.MaxInclusive }, }, { "bounded with unbounded max", NewInterval("1.0.0", "3.0.0", true, true), GreaterThanInterval("2.0.0", true), false, func(r *Interval) bool { return r.Min == "1.0.0" && r.MinInclusive && r.Max == "" }, }, { "unbounded min with unbounded max", LessThanInterval("2.0.0", true), GreaterThanInterval("1.0.0", true), false, func(r *Interval) bool { return r.Min == "" && r.Max == "" }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.i1.Union(tt.i2) if tt.isNil { if result != nil { t.Errorf("Union() = %v, want nil", result) } } else { if result == nil { t.Error("Union() = nil, want non-nil") } else if !tt.expect(result) { t.Errorf("Union() = %v, didn't meet expectations", result) } } }) } } func TestIntervalString(t *testing.T) { tests := []struct { name string i Interval want string }{ {"empty", EmptyInterval(), "empty"}, {"unbounded", UnboundedInterval(), "(-inf,+inf)"}, {"[1.0.0,2.0.0]", NewInterval("1.0.0", "2.0.0", true, true), "[1.0.0,2.0.0]"}, {"(1.0.0,2.0.0)", NewInterval("1.0.0", "2.0.0", false, false), "(1.0.0,2.0.0)"}, {"[1.0.0,2.0.0)", NewInterval("1.0.0", "2.0.0", true, false), "[1.0.0,2.0.0)"}, {">=1.0.0", GreaterThanInterval("1.0.0", true), "[1.0.0,+inf)"}, {">1.0.0", GreaterThanInterval("1.0.0", false), "(1.0.0,+inf)"}, {"<=2.0.0", LessThanInterval("2.0.0", true), "(-inf,2.0.0]"}, {"<2.0.0", LessThanInterval("2.0.0", false), "(-inf,2.0.0)"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.i.String() if got != tt.want { t.Errorf("String() = %q, want %q", got, tt.want) } }) } } golang-github-git-pkgs-vers-0.2.4/parser.go000066400000000000000000000447731517221076300206120ustar00rootroot00000000000000package vers import ( "fmt" "regexp" "strings" ) var versURIRegex = regexp.MustCompile(`^vers:([^/]+)/(.*)$`) // Parser handles parsing of vers URIs and native package manager syntax. type Parser struct{} // NewParser creates a new Parser. func NewParser() *Parser { return &Parser{} } // Parse parses a vers URI string into a Range. func (p *Parser) Parse(versURI string) (*Range, error) { matches := versURIRegex.FindStringSubmatch(versURI) if matches == nil { return nil, fmt.Errorf("invalid vers URI format: %s", versURI) } scheme := matches[1] constraintsStr := matches[2] // Handle wildcard for unbounded range if constraintsStr == "*" || constraintsStr == "" { return Unbounded(), nil } return p.parseConstraints(constraintsStr, scheme) } // ParseNative parses a native package manager version range into a Range. func (p *Parser) ParseNative(constraint string, scheme string) (*Range, error) { switch scheme { case "npm": return p.parseNpmRange(constraint) case "gem", "rubygems": return p.parseGemRange(constraint) case "pypi": return p.parsePypiRange(constraint) case "maven": return p.parseMavenRange(constraint) case "nuget": //nolint:goconst return p.parseNugetRange(constraint) case "cargo": return p.parseCargoRange(constraint) case "go", "golang": return p.parseGoRange(constraint) case "hex", "elixir": return p.parseHexRange(constraint) case "deb", "debian": return p.parseDebianRange(constraint) case "rpm": return p.parseRpmRange(constraint) default: return p.parseConstraints(constraint, scheme) } } // ToVersString converts a Range back to a vers URI string. func (p *Parser) ToVersString(r *Range, scheme string) string { if r.IsUnbounded() && len(r.Exclusions) == 0 && len(r.RawConstraints) == 0 { return fmt.Sprintf("vers:%s/*", scheme) } // Check if empty but has raw constraints (preserve them for output) if r.IsEmpty() && len(r.RawConstraints) == 0 { return fmt.Sprintf("vers:%s/", scheme) } // Use RawConstraints if available (for preserving original structure) intervals := r.Intervals if len(r.RawConstraints) > 0 { intervals = r.RawConstraints } var constraints []constraintWithVersion for _, interval := range intervals { if interval.Min == interval.Max && interval.MinInclusive && interval.MaxInclusive && interval.Min != "" { // Exact version - no operator needed per VERS spec constraints = append(constraints, constraintWithVersion{ str: normalizeVersion(interval.Min, scheme), sortKey: interval.Min, }) } else { if interval.Min != "" { op := ">" if interval.MinInclusive { op = ">=" } constraints = append(constraints, constraintWithVersion{ str: op + normalizeVersion(interval.Min, scheme), sortKey: interval.Min, }) } if interval.Max != "" { op := "<" if interval.MaxInclusive { op = "<=" } constraints = append(constraints, constraintWithVersion{ str: op + normalizeVersion(interval.Max, scheme), sortKey: interval.Max, }) } } } // Add exclusions for _, exc := range r.Exclusions { constraints = append(constraints, constraintWithVersion{ str: "!=" + normalizeVersion(exc, scheme), sortKey: exc, }) } // Sort constraints by version sortConstraintsByVersion(constraints) var strs []string for _, c := range constraints { strs = append(strs, c.str) } return fmt.Sprintf("vers:%s/%s", scheme, strings.Join(strs, "|")) } // constraintWithVersion holds a constraint string and its sort key. type constraintWithVersion struct { str string sortKey string } // sortConstraintsByVersion sorts constraints by their version in ascending order. func sortConstraintsByVersion(constraints []constraintWithVersion) { // Simple bubble sort to avoid import for i := 0; i < len(constraints); i++ { for j := i + 1; j < len(constraints); j++ { if CompareVersions(constraints[i].sortKey, constraints[j].sortKey) > 0 { constraints[i], constraints[j] = constraints[j], constraints[i] } } } } // normalizeVersion normalizes a version string for output. // For semver-based schemes, this ensures 3-part versions (1.1 -> 1.1.0). func normalizeVersion(version, scheme string) string { // Don't normalize if it already has prerelease info if strings.Contains(version, "-") { return version } // Count the number of dots dots := strings.Count(version, ".") switch scheme { case "npm", "cargo", "nuget": // These schemes use semver, normalize to 3 parts switch dots { case 0: return version + ".0.0" case 1: return version + ".0" } } return version } func (p *Parser) parseConstraints(constraintsStr, scheme string) (*Range, error) { parts := strings.Split(constraintsStr, "|") var intervals []Interval var exclusions []string for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } constraint, err := parseConstraintWithScheme(part, scheme) if err != nil { return nil, err } if constraint.IsExclusion() { exclusions = append(exclusions, constraint.Version) } else { interval, ok := constraint.ToInterval() if ok { intervals = append(intervals, interval) } } } // Collect all intervals - they form a union // Then intersect overlapping intervals to form proper ranges result := intersectConsecutiveIntervals(intervals) // If we only have exclusions and no other constraints, start with unbounded range if result == nil { if len(exclusions) > 0 { result = Unbounded() } else { result = &Range{} } } result.Exclusions = exclusions return result, nil } // intersectConsecutiveIntervals handles VERS constraint semantics: // - Consecutive unbounded intervals (like >=X followed by =1.0.0 <2.0.0, || func (p *Parser) parseNpmRange(s string) (*Range, error) { s = strings.TrimSpace(s) if s == "" || s == "*" || s == "x" || s == "X" { return Unbounded(), nil } // Handle || (OR) if strings.Contains(s, "||") { parts := strings.Split(s, "||") var result *Range for _, part := range parts { // Each OR part may contain AND constraints, so recurse r, err := p.parseNpmRange(strings.TrimSpace(part)) if err != nil { return nil, err } if result == nil { result = r } else { result = result.Union(r) } } return result, nil } // Handle space-separated AND constraints if strings.Contains(s, " ") && !strings.Contains(s, " - ") { parts := tokenizeNpmConstraints(s) var result *Range for _, part := range parts { r, err := p.parseNpmSingleRange(part) if err != nil { return nil, err } if result == nil { result = r } else { result = result.Intersect(r) } } return result, nil } return p.parseNpmSingleRange(s) } // tokenizeNpmConstraints splits an npm constraint string into individual constraints, // properly handling operators followed by spaces (e.g., ">= 1.0.0" stays as one token). func tokenizeNpmConstraints(s string) []string { tokens := strings.Fields(s) if len(tokens) <= 1 { return tokens } // Merge operator-only tokens with the following version token var result []string i := 0 for i < len(tokens) { token := tokens[i] // Check if this token is just an operator if isOperatorOnly(token) && i+1 < len(tokens) { // Merge with next token result = append(result, token+tokens[i+1]) i += 2 } else { result = append(result, token) i++ } } return result } // isOperatorOnly checks if a string is just an operator without a version. func isOperatorOnly(s string) bool { switch s { case ">=", "<=", ">", "<", "=", "!=": return true } return false } // extractOperator extracts an operator prefix from a constraint string. // Returns the operator and the remaining version string. func extractOperator(s string) (string, string) { for _, op := range []string{">=", "<=", "!=", ">", "<", "="} { if strings.HasPrefix(s, op) { return op, s[len(op):] } } return "", s } func (p *Parser) parseNpmSingleRange(s string) (*Range, error) { // Caret range: ^1.2.3 if strings.HasPrefix(s, "^") { return p.parseCaretRange(s[1:]) } // Tilde range: ~1.2.3 if strings.HasPrefix(s, "~") { return p.parseTildeRange(s[1:]) } // Hyphen range: 1.2.3 - 2.0.0 if strings.Contains(s, " - ") { parts := strings.SplitN(s, " - ", 2) //nolint:mnd return NewRange([]Interval{ NewInterval(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), true, true), }), nil } // X-range: 1.x, 1.2.x (also handle operator + x-range like >=1.x) if strings.HasSuffix(s, ".x") || strings.HasSuffix(s, ".X") || strings.HasSuffix(s, ".*") { // Check if there's an operator prefix op, version := extractOperator(s) if op != "" { // For >=X.x or >X.x, the x-range defines the minimum xRange, err := p.parseXRange(version) if err != nil { return nil, err } // >=2.2.x means >=2.2.0 (start of the x-range) // The x-range itself is the answer for >= with x-range return xRange, nil } return p.parseXRange(s) } // Standard constraint constraint, err := ParseConstraint(s) if err != nil { return nil, err } interval, ok := constraint.ToInterval() if !ok { if constraint.IsExclusion() { return Unbounded().Exclude(constraint.Version), nil } return nil, fmt.Errorf("invalid constraint: %s", s) } return NewRange([]Interval{interval}), nil } // ^1.2.3 := >=1.2.3 <2.0.0 func (p *Parser) parseCaretRange(version string) (*Range, error) { v, err := ParseVersion(version) if err != nil { return nil, err } var upper string switch { case v.Major > 0: upper = fmt.Sprintf("%d.0.0", v.Major+1) case v.Minor > 0: upper = fmt.Sprintf("0.%d.0", v.Minor+1) default: upper = fmt.Sprintf("0.0.%d", v.Patch+1) } return NewRange([]Interval{ NewInterval(version, upper, true, false), }), nil } // ~1.2.3 := >=1.2.3 <1.3.0 // ~1.2.3-pre := >=1.2.3-pre <1.2.3 OR >=1.2.3 <1.2.4 (for prerelease handling) func (p *Parser) parseTildeRange(version string) (*Range, error) { v, err := ParseVersion(version) if err != nil { return nil, err } // If there's a prerelease, we need special handling // npm semver only matches prereleases if they're on the same major.minor.patch if v.Prerelease != "" { // Create two intervals: // 1. Prereleases from the specified version to the release version // 2. Release versions for patch updates baseVersion := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) nextPatch := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch+1) return NewRange([]Interval{ // Prerelease interval: >=version =baseVersion = 2 { //nolint:mnd // ~1.2.3 := >=1.2.3 <1.3.0 // ~1.0.0 := >=1.0.0 <1.1.0 // ~1.0 := >=1.0.0 <1.1.0 upper = fmt.Sprintf("%d.%d.0", v.Major, v.Minor+1) } else { // ~1 := >=1.0.0 <2.0.0 upper = fmt.Sprintf("%d.0.0", v.Major+1) } return NewRange([]Interval{ NewInterval(version, upper, true, false), }), nil } // 1.x := >=1.0.0 <2.0.0 func (p *Parser) parseXRange(s string) (*Range, error) { s = strings.TrimSuffix(s, ".x") s = strings.TrimSuffix(s, ".X") s = strings.TrimSuffix(s, ".*") parts := strings.Split(s, ".") if len(parts) == 1 { major := parts[0] v, err := ParseVersion(major) if err != nil { return nil, err } return NewRange([]Interval{ NewInterval(fmt.Sprintf("%d.0.0", v.Major), fmt.Sprintf("%d.0.0", v.Major+1), true, false), }), nil } v, err := ParseVersion(s) if err != nil { return nil, err } return NewRange([]Interval{ NewInterval(fmt.Sprintf("%d.%d.0", v.Major, v.Minor), fmt.Sprintf("%d.%d.0", v.Major, v.Minor+1), true, false), }), nil } // gem: ~> 1.2, >= 1.0, < 2.0 func (p *Parser) parseGemRange(s string) (*Range, error) { s = strings.TrimSpace(s) // Pessimistic operator: ~> 1.2.3 if strings.HasPrefix(s, "~>") { version := strings.TrimSpace(s[2:]) return p.parsePessimisticRange(version) } // Comma-separated constraints if strings.Contains(s, ",") { parts := strings.Split(s, ",") var result *Range for _, part := range parts { r, err := p.parseGemRange(strings.TrimSpace(part)) if err != nil { return nil, err } if result == nil { result = r } else { result = result.Intersect(r) } } return result, nil } // Standard constraint return p.parseConstraints(s, "gem") } // ~> 1.2.3 := >= 1.2.3, < 1.3 // ~> 1.2 := >= 1.2, < 2.0 func (p *Parser) parsePessimisticRange(version string) (*Range, error) { v, err := ParseVersion(version) if err != nil { return nil, err } // Count segments in original version string to preserve precision segments := strings.Count(version, ".") + 1 var upper string if segments >= 3 { //nolint:mnd // ~> 1.2.3 bumps minor: < 1.3 upper = fmt.Sprintf("%d.%d", v.Major, v.Minor+1) } else { // ~> 1.2 or ~> 1 bumps major: < 2.0 upper = fmt.Sprintf("%d.0", v.Major+1) } return NewRange([]Interval{ NewInterval(version, upper, true, false), }), nil } // pypi: >=1.0,<2.0, ~=1.4.2, !=1.5.0 func (p *Parser) parsePypiRange(s string) (*Range, error) { s = strings.TrimSpace(s) // Compatible release: ~=1.4.2 if strings.HasPrefix(s, "~=") { version := strings.TrimSpace(s[2:]) return p.parsePessimisticRange(version) } // Comma-separated constraints if strings.Contains(s, ",") { parts := strings.Split(s, ",") constraintStr := strings.Join(parts, "|") return p.parseConstraints(constraintStr, "pypi") } return p.parseConstraints(s, "pypi") } // maven: [1.0,2.0), (1.0,2.0], [1.0,) func (p *Parser) parseMavenRange(s string) (*Range, error) { s = strings.TrimSpace(s) // Bracket notation if (strings.HasPrefix(s, "[") || strings.HasPrefix(s, "(")) && (strings.HasSuffix(s, "]") || strings.HasSuffix(s, ")")) { return p.parseBracketRange(s) } // Simple version (minimum version in Maven) if matched, _ := regexp.MatchString(`^[0-9]`, s); matched { return NewRange([]Interval{ GreaterThanInterval(s, true), }), nil } return p.parseConstraints(s, "maven") } func (p *Parser) parseBracketRange(s string) (*Range, error) { minInclusive := s[0] == '[' maxInclusive := s[len(s)-1] == ']' inner := s[1 : len(s)-1] parts := strings.SplitN(inner, ",", 2) //nolint:mnd if len(parts) == 1 { // Exact version: [1.0] return Exact(strings.TrimSpace(parts[0])), nil } min := strings.TrimSpace(parts[0]) max := strings.TrimSpace(parts[1]) interval := Interval{ Min: min, Max: max, MinInclusive: minInclusive, MaxInclusive: maxInclusive, } if min == "" { interval.Min = "" } if max == "" { interval.Max = "" } return NewRange([]Interval{interval}), nil } // nuget: same as maven func (p *Parser) parseNugetRange(s string) (*Range, error) { return p.parseMavenRange(s) } // cargo: ^1.2.3, ~1.2.3, >=1.0.0 func (p *Parser) parseCargoRange(s string) (*Range, error) { // Cargo uses similar syntax to npm return p.parseNpmRange(s) } // go: >=1.0.0, <2.0.0 func (p *Parser) parseGoRange(s string) (*Range, error) { // Go uses comma-separated constraints if strings.Contains(s, ",") { parts := strings.Split(s, ",") var result *Range for _, part := range parts { constraint, err := parseConstraintWithScheme(strings.TrimSpace(part), "go") if err != nil { return nil, err } interval, ok := constraint.ToInterval() if !ok { continue } r := NewRange([]Interval{interval}) if result == nil { result = r } else { result = result.Intersect(r) } } return result, nil } return p.parseConstraints(s, "go") } // hex/elixir: ~> 1.2.3, >= 1.0.0 and < 2.0.0, ~> 1.0 or ~> 2.0 func (p *Parser) parseHexRange(s string) (*Range, error) { s = strings.TrimSpace(s) // Handle "or" disjunction first if strings.Contains(s, " or ") { parts := strings.Split(s, " or ") var result *Range for _, part := range parts { r, err := p.parseHexSingleRange(strings.TrimSpace(part)) if err != nil { return nil, err } if result == nil { result = r } else { result = result.Union(r) } } return result, nil } return p.parseHexSingleRange(s) } func (p *Parser) parseHexSingleRange(s string) (*Range, error) { // Handle "and" conjunction if strings.Contains(s, " and ") { parts := strings.Split(s, " and ") var result *Range for _, part := range parts { r, err := p.parseHexConstraint(strings.TrimSpace(part)) if err != nil { return nil, err } if result == nil { result = r } else { result = result.Intersect(r) } } return result, nil } return p.parseHexConstraint(s) } func (p *Parser) parseHexConstraint(s string) (*Range, error) { // Pessimistic operator: ~> 1.2.3 if strings.HasPrefix(s, "~>") { version := strings.TrimSpace(s[2:]) return p.parsePessimisticRange(version) } // Normalize == to = for internal constraint parsing normalized := strings.Replace(s, "==", "=", 1) constraint, err := ParseConstraint(normalized) if err != nil { return nil, err } if constraint.IsExclusion() { return Unbounded().Exclude(constraint.Version), nil } interval, ok := constraint.ToInterval() if !ok { return nil, fmt.Errorf("invalid hex constraint: %s", s) } return NewRange([]Interval{interval}), nil } // debian: >= 1.0, << 2.0 func (p *Parser) parseDebianRange(s string) (*Range, error) { // Convert Debian operators to standard s = strings.ReplaceAll(s, ">>", ">") s = strings.ReplaceAll(s, "<<", "<") return p.parseConstraints(s, "deb") } // rpm: >= 1.0, <= 2.0 func (p *Parser) parseRpmRange(s string) (*Range, error) { return p.parseConstraints(s, "rpm") } golang-github-git-pkgs-vers-0.2.4/parser_test.go000066400000000000000000000372601517221076300216420ustar00rootroot00000000000000package vers import "testing" func TestParseVersURI(t *testing.T) { tests := []struct { name string input string version string want bool wantErr bool }{ // Basic VERS URI {"exact version", "vers:npm/=1.0.0", "1.0.0", true, false}, {"exact version excludes other", "vers:npm/=1.0.0", "1.0.1", false, false}, {"greater than", "vers:npm/>=1.0.0", "1.5.0", true, false}, {"less than", "vers:npm/<2.0.0", "1.9.9", true, false}, {"combined constraints", "vers:npm/>=1.0.0|<2.0.0", "1.5.0", true, false}, {"combined excludes below", "vers:npm/>=1.0.0|<2.0.0", "0.9.0", false, false}, {"combined excludes above", "vers:npm/>=1.0.0|<2.0.0", "2.0.0", false, false}, {"exclusion", "vers:npm/>=1.0.0|!=1.5.0", "1.5.0", false, false}, {"exclusion allows other", "vers:npm/>=1.0.0|!=1.5.0", "1.6.0", true, false}, // Wildcard {"wildcard matches all", "vers:npm/*", "999.0.0", true, false}, {"empty constraints", "vers:npm/", "999.0.0", true, false}, // Different schemes {"gem scheme", "vers:gem/>=1.0.0", "1.5.0", true, false}, {"pypi scheme", "vers:pypi/>=1.0.0", "1.5.0", true, false}, {"maven scheme", "vers:maven/>=1.0.0", "1.5.0", true, false}, // Error cases {"invalid format", "invalid", "", false, true}, {"missing scheme", "vers:/>=1.0.0", "", false, true}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.Parse(tt.input) if (err != nil) != tt.wantErr { t.Errorf("Parse(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) return } if err != nil { return } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseNpmRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ // Caret ranges {"^1.2.3 includes exact", "^1.2.3", "1.2.3", true}, {"^1.2.3 includes patch", "^1.2.3", "1.2.4", true}, {"^1.2.3 includes minor", "^1.2.3", "1.9.0", true}, {"^1.2.3 excludes major", "^1.2.3", "2.0.0", false}, {"^0.2.3 includes patch", "^0.2.3", "0.2.5", true}, {"^0.2.3 excludes minor", "^0.2.3", "0.3.0", false}, {"^0.0.3 excludes patch", "^0.0.3", "0.0.4", false}, // Tilde ranges {"~1.2.3 includes patch", "~1.2.3", "1.2.5", true}, {"~1.2.3 excludes minor", "~1.2.3", "1.3.0", false}, {"~1.2.0 includes patch", "~1.2.0", "1.2.9", true}, {"~1.2.0 excludes minor", "~1.2.0", "1.3.0", false}, {"~1.0.0 includes patch", "~1.0.0", "1.0.9", true}, {"~1.0.0 excludes minor", "~1.0.0", "1.1.0", false}, {"~1.0 includes patch", "~1.0", "1.0.9", true}, {"~1.0 excludes minor", "~1.0", "1.1.0", false}, {"~1 includes minor", "~1", "1.9.0", true}, {"~1 excludes major", "~1", "2.0.0", false}, // X-ranges {"1.x includes 1.0.0", "1.x", "1.0.0", true}, {"1.x includes 1.9.9", "1.x", "1.9.9", true}, {"1.x excludes 2.0.0", "1.x", "2.0.0", false}, {"1.2.x includes 1.2.0", "1.2.x", "1.2.0", true}, {"1.2.x excludes 1.3.0", "1.2.x", "1.3.0", false}, // Hyphen ranges {"1.0.0 - 2.0.0 includes min", "1.0.0 - 2.0.0", "1.0.0", true}, {"1.0.0 - 2.0.0 includes max", "1.0.0 - 2.0.0", "2.0.0", true}, {"1.0.0 - 2.0.0 includes middle", "1.0.0 - 2.0.0", "1.5.0", true}, {"1.0.0 - 2.0.0 excludes below", "1.0.0 - 2.0.0", "0.9.0", false}, // OR ranges {"|| includes first", "1.0.0 || 2.0.0", "1.0.0", true}, {"|| includes second", "1.0.0 || 2.0.0", "2.0.0", true}, {"|| excludes other", "1.0.0 || 2.0.0", "1.5.0", false}, // AND ranges (space-separated) {"AND satisfies both", ">=1.0.0 <2.0.0", "1.5.0", true}, {"AND fails first", ">=1.0.0 <2.0.0", "0.9.0", false}, {"AND fails second", ">=1.0.0 <2.0.0", "2.0.0", false}, // Wildcards {"* matches all", "*", "999.0.0", true}, {"x matches all", "x", "999.0.0", true}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "npm") if err != nil { t.Fatalf("ParseNative(%q, npm) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseGemRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ // Pessimistic operator {"~> 1.2.3 includes patch", "~> 1.2.3", "1.2.5", true}, {"~> 1.2.3 excludes minor", "~> 1.2.3", "1.3.0", false}, {"~> 1.2 includes minor", "~> 1.2", "1.5.0", true}, {"~> 1.2 excludes major", "~> 1.2", "2.0.0", false}, // Standard constraints {">= 1.0.0 includes", ">= 1.0.0", "1.5.0", true}, {">= 1.0.0 excludes below", ">= 1.0.0", "0.9.0", false}, {"< 2.0.0 includes", "< 2.0.0", "1.9.9", true}, {"< 2.0.0 excludes", "< 2.0.0", "2.0.0", false}, // Comma-separated (AND) {">= 1.0.0, < 2.0.0 includes", ">= 1.0.0, < 2.0.0", "1.5.0", true}, {">= 1.0.0, < 2.0.0 excludes below", ">= 1.0.0, < 2.0.0", "0.9.0", false}, {">= 1.0.0, < 2.0.0 excludes above", ">= 1.0.0, < 2.0.0", "2.0.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "gem") if err != nil { t.Fatalf("ParseNative(%q, gem) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParsePypiRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ // Compatible release {"~=1.4.2 includes patch", "~=1.4.2", "1.4.5", true}, {"~=1.4.2 excludes minor", "~=1.4.2", "1.5.0", false}, // Standard constraints {">=1.0.0 includes", ">=1.0.0", "1.5.0", true}, {"<2.0.0 includes", "<2.0.0", "1.9.9", true}, {"!=1.5.0 excludes", "!=1.5.0", "1.5.0", false}, {"!=1.5.0 includes other", "!=1.5.0", "1.4.0", true}, // Comma-separated {">=1.0.0,<2.0.0 includes", ">=1.0.0,<2.0.0", "1.5.0", true}, {">=1.0.0,<2.0.0 excludes below", ">=1.0.0,<2.0.0", "0.9.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "pypi") if err != nil { t.Fatalf("ParseNative(%q, pypi) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseMavenRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ // Bracket notation {"[1.0,2.0] includes min", "[1.0,2.0]", "1.0", true}, {"[1.0,2.0] includes max", "[1.0,2.0]", "2.0", true}, {"[1.0,2.0] includes middle", "[1.0,2.0]", "1.5", true}, {"[1.0,2.0) excludes max", "[1.0,2.0)", "2.0", false}, {"(1.0,2.0] excludes min", "(1.0,2.0]", "1.0", false}, // Open-ended {"[1.0,) includes above", "[1.0,)", "5.0.0", true}, {"[1.0,) excludes below", "[1.0,)", "0.9.0", false}, {"(,2.0] includes below", "(,2.0]", "1.0.0", true}, {"(,2.0] excludes above", "(,2.0]", "2.0.1", false}, // Exact version {"[1.0] exact match", "[1.0]", "1.0", true}, {"[1.0] excludes other", "[1.0]", "1.1", false}, // Simple version (minimum) {"1.0 includes minimum", "1.0", "1.0", true}, {"1.0 includes above", "1.0", "2.0.0", true}, {"1.0 excludes below", "1.0", "0.9.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "maven") if err != nil { t.Fatalf("ParseNative(%q, maven) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseNugetRange(t *testing.T) { // NuGet uses the same syntax as Maven tests := []struct { name string input string version string want bool }{ {"[1.0,2.0] includes middle", "[1.0,2.0]", "1.5", true}, {"[1.0,2.0) excludes max", "[1.0,2.0)", "2.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "nuget") if err != nil { t.Fatalf("ParseNative(%q, nuget) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseCargoRange(t *testing.T) { // Cargo uses npm-like syntax tests := []struct { name string input string version string want bool }{ {"^1.2.3 includes minor", "^1.2.3", "1.9.0", true}, {"^1.2.3 excludes major", "^1.2.3", "2.0.0", false}, {"~1.2.3 includes patch", "~1.2.3", "1.2.9", true}, {"~1.2.3 excludes minor", "~1.2.3", "1.3.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "cargo") if err != nil { t.Fatalf("ParseNative(%q, cargo) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseGoRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ {">=1.0.0 includes", ">=1.0.0", "1.5.0", true}, {"<2.0.0 includes", "<2.0.0", "1.9.9", true}, {">=1.0.0,<2.0.0 includes", ">=1.0.0,<2.0.0", "1.5.0", true}, {">=1.0.0,<2.0.0 excludes below", ">=1.0.0,<2.0.0", "0.9.0", false}, {">=1.0.0,<2.0.0 excludes above", ">=1.0.0,<2.0.0", "2.0.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "go") if err != nil { t.Fatalf("ParseNative(%q, go) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseDebianRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ {">= 1.0 includes", ">= 1.0", "1.5.0", true}, {">> 1.0 includes", ">> 1.0", "1.5.0", true}, {">> 1.0 excludes exact", ">> 1.0", "1.0", false}, {"<< 2.0 includes", "<< 2.0", "1.9.9", true}, {"<< 2.0 excludes exact", "<< 2.0", "2.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "deb") if err != nil { t.Fatalf("ParseNative(%q, deb) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseRpmRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ {">= 1.0 includes", ">= 1.0", "1.5.0", true}, {"<= 2.0 includes", "<= 2.0", "1.9.9", true}, {"<= 2.0 includes exact", "<= 2.0", "2.0", true}, {"< 2.0 excludes exact", "< 2.0", "2.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "rpm") if err != nil { t.Fatalf("ParseNative(%q, rpm) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseHexRange(t *testing.T) { tests := []struct { name string input string version string want bool }{ // Pessimistic operator {"~> 1.2.3 includes exact", "~> 1.2.3", "1.2.3", true}, {"~> 1.2.3 includes patch", "~> 1.2.3", "1.2.9", true}, {"~> 1.2.3 excludes minor", "~> 1.2.3", "1.3.0", false}, {"~> 1.2.3 excludes below", "~> 1.2.3", "1.2.2", false}, {"~> 2.0 includes minor", "~> 2.0", "2.9.9", true}, {"~> 2.0 excludes major", "~> 2.0", "3.0.0", false}, {"~> 2.1 includes above", "~> 2.1", "2.9.9", true}, {"~> 2.1 excludes major", "~> 2.1", "3.0.0", false}, {"~> 2.1 excludes below", "~> 2.1", "2.0.9", false}, // Exact match with == {"== 1.2.3 matches", "== 1.2.3", "1.2.3", true}, {"== 1.2.3 excludes above", "== 1.2.3", "1.2.4", false}, {"== 1.2.3 excludes below", "== 1.2.3", "1.2.2", false}, // Comparison operators {">= 1.0.0 includes exact", ">= 1.0.0", "1.0.0", true}, {">= 1.0.0 includes above", ">= 1.0.0", "2.0.0", true}, {">= 1.0.0 excludes below", ">= 1.0.0", "0.9.0", false}, {"> 1.0.0 excludes exact", "> 1.0.0", "1.0.0", false}, {"> 1.0.0 includes above", "> 1.0.0", "1.0.1", true}, {"<= 2.0.0 includes exact", "<= 2.0.0", "2.0.0", true}, {"<= 2.0.0 excludes above", "<= 2.0.0", "2.0.1", false}, {"< 2.0.0 excludes exact", "< 2.0.0", "2.0.0", false}, {"< 2.0.0 includes below", "< 2.0.0", "1.9.9", true}, // Not equal {"!= 1.5.0 includes other", "!= 1.5.0", "1.4.0", true}, {"!= 1.5.0 includes above", "!= 1.5.0", "1.6.0", true}, {"!= 1.5.0 excludes exact", "!= 1.5.0", "1.5.0", false}, // And conjunction {">= 1.0.0 and < 2.0.0 includes", ">= 1.0.0 and < 2.0.0", "1.5.0", true}, {">= 1.0.0 and < 2.0.0 excludes below", ">= 1.0.0 and < 2.0.0", "0.9.0", false}, {">= 1.0.0 and < 2.0.0 excludes above", ">= 1.0.0 and < 2.0.0", "2.0.0", false}, // Or disjunction {"~> 1.0 or ~> 2.0 includes first", "~> 1.0 or ~> 2.0", "1.5.0", true}, {"~> 1.0 or ~> 2.0 includes second", "~> 1.0 or ~> 2.0", "2.5.0", true}, {"~> 1.0 or ~> 2.0 excludes above", "~> 1.0 or ~> 2.0", "3.0.0", false}, // Combined and/or with exclusion {"~> 1.0 and != 1.5.0 includes", "~> 1.0 and != 1.5.0", "1.4.0", true}, {"~> 1.0 and != 1.5.0 excludes version", "~> 1.0 and != 1.5.0", "1.5.0", false}, {"~> 1.0 and != 1.5.0 excludes major", "~> 1.0 and != 1.5.0", "2.0.0", false}, } parser := NewParser() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := parser.ParseNative(tt.input, "hex") if err != nil { t.Fatalf("ParseNative(%q, hex) error = %v", tt.input, err) } got := r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestParseHexElixirAlias(t *testing.T) { parser := NewParser() r, err := parser.ParseNative("~> 1.2.3", "elixir") if err != nil { t.Fatalf("ParseNative with elixir scheme error = %v", err) } if !r.Contains("1.2.3") { t.Error("elixir scheme should contain 1.2.3") } if r.Contains("1.3.0") { t.Error("elixir scheme should not contain 1.3.0") } } func TestToVersString(t *testing.T) { parser := NewParser() tests := []struct { name string r *Range scheme string want string }{ {"unbounded", Unbounded(), "npm", "vers:npm/*"}, {"empty", Empty(), "npm", "vers:npm/"}, {"exact", Exact("1.0.0"), "npm", "vers:npm/1.0.0"}, {"greater than", GreaterThan("1.0.0", true), "npm", "vers:npm/>=1.0.0"}, {"less than", LessThan("2.0.0", false), "npm", "vers:npm/<2.0.0"}, { "range", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, false)}), "npm", "vers:npm/>=1.0.0|<2.0.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parser.ToVersString(tt.r, tt.scheme) if got != tt.want { t.Errorf("ToVersString() = %q, want %q", got, tt.want) } }) } } func TestPublicAPISatisfies(t *testing.T) { tests := []struct { name string version string constraint string scheme string want bool }{ {"vers URI", "1.5.0", "vers:npm/>=1.0.0", "", true}, {"npm native", "1.5.0", "^1.0.0", "npm", true}, {"gem native", "1.5.0", "~> 1.0", "gem", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Satisfies(tt.version, tt.constraint, tt.scheme) if err != nil { t.Fatalf("Satisfies() error = %v", err) } if got != tt.want { t.Errorf("Satisfies() = %v, want %v", got, tt.want) } }) } } golang-github-git-pkgs-vers-0.2.4/range.go000066400000000000000000000130321517221076300203720ustar00rootroot00000000000000package vers import "strings" // Range represents a version range as a collection of intervals. // Multiple intervals represent a union (OR) of ranges. type Range struct { Intervals []Interval Exclusions []string // Versions to exclude (from != constraints) // RawConstraints stores the original constraints for VERS output (not merged) RawConstraints []Interval } // NewRange creates a new Range from intervals. func NewRange(intervals []Interval) *Range { return &Range{Intervals: intervals} } // Contains checks if the range contains the given version. func (r *Range) Contains(version string) bool { // Check exclusions first for _, exc := range r.Exclusions { if CompareVersions(version, exc) == 0 { return false } } // Check if version is in any interval for _, interval := range r.Intervals { if interval.Contains(version) { return true } } return false } // IsEmpty returns true if this range matches no versions. func (r *Range) IsEmpty() bool { if len(r.Intervals) == 0 { return true } for _, interval := range r.Intervals { if !interval.IsEmpty() { return false } } return true } // IsUnbounded returns true if this range matches all versions. func (r *Range) IsUnbounded() bool { if len(r.Exclusions) > 0 { return false } for _, interval := range r.Intervals { if interval.IsUnbounded() { return true } } return false } // Union returns a new Range that is the union of this range and another. func (r *Range) Union(other *Range) *Range { if r.IsEmpty() { return other } if other.IsEmpty() { return r } // Combine all intervals allIntervals := make([]Interval, 0, len(r.Intervals)+len(other.Intervals)) allIntervals = append(allIntervals, r.Intervals...) allIntervals = append(allIntervals, other.Intervals...) // Merge overlapping intervals for containment checking merged := mergeIntervals(allIntervals) // Combine exclusions (intersection of exclusions for union) exclusions := make([]string, 0) for _, e := range r.Exclusions { for _, oe := range other.Exclusions { if e == oe { exclusions = append(exclusions, e) break } } } // Combine raw constraints (unmerged) for VERS output rawConstraints := make([]Interval, 0, len(r.RawConstraints)+len(other.RawConstraints)) if len(r.RawConstraints) > 0 { rawConstraints = append(rawConstraints, r.RawConstraints...) } else { rawConstraints = append(rawConstraints, r.Intervals...) } if len(other.RawConstraints) > 0 { rawConstraints = append(rawConstraints, other.RawConstraints...) } else { rawConstraints = append(rawConstraints, other.Intervals...) } return &Range{Intervals: merged, Exclusions: exclusions, RawConstraints: rawConstraints} } // Intersect returns a new Range that is the intersection of this range and another. func (r *Range) Intersect(other *Range) *Range { // Combine raw constraints for VERS output (preserved even if result is empty) rawConstraints := make([]Interval, 0, len(r.RawConstraints)+len(other.RawConstraints)) if len(r.RawConstraints) > 0 { rawConstraints = append(rawConstraints, r.RawConstraints...) } else { rawConstraints = append(rawConstraints, r.Intervals...) } if len(other.RawConstraints) > 0 { rawConstraints = append(rawConstraints, other.RawConstraints...) } else { rawConstraints = append(rawConstraints, other.Intervals...) } if r.IsEmpty() || other.IsEmpty() { return &Range{RawConstraints: rawConstraints} } // Intersect each pair of intervals var result []Interval for _, i1 := range r.Intervals { for _, i2 := range other.Intervals { intersection := i1.Intersect(i2) if !intersection.IsEmpty() { result = append(result, intersection) } } } // Merge overlapping intervals merged := mergeIntervals(result) // Combine exclusions (union of exclusions for intersection) exclusions := make([]string, 0, len(r.Exclusions)+len(other.Exclusions)) exclusions = append(exclusions, r.Exclusions...) for _, e := range other.Exclusions { found := false for _, existing := range exclusions { if e == existing { found = true break } } if !found { exclusions = append(exclusions, e) } } return &Range{Intervals: merged, Exclusions: exclusions, RawConstraints: rawConstraints} } // Exclude returns a new Range that excludes the given version. func (r *Range) Exclude(version string) *Range { exclusions := make([]string, len(r.Exclusions), len(r.Exclusions)+1) copy(exclusions, r.Exclusions) exclusions = append(exclusions, version) return &Range{ Intervals: r.Intervals, Exclusions: exclusions, } } // String returns a string representation of the range. func (r *Range) String() string { if r.IsEmpty() { return "empty" } if r.IsUnbounded() && len(r.Exclusions) == 0 { return "*" } var parts []string for _, interval := range r.Intervals { parts = append(parts, interval.String()) } result := strings.Join(parts, " | ") if len(r.Exclusions) > 0 { result += " excluding " + strings.Join(r.Exclusions, ", ") } return result } // mergeIntervals merges overlapping intervals into a minimal set. func mergeIntervals(intervals []Interval) []Interval { if len(intervals) <= 1 { return intervals } // Simple implementation: try to merge each pair result := make([]Interval, 0, len(intervals)) for _, interval := range intervals { if interval.IsEmpty() { continue } merged := false for i, existing := range result { if union := existing.Union(interval); union != nil { result[i] = *union merged = true break } } if !merged { result = append(result, interval) } } return result } golang-github-git-pkgs-vers-0.2.4/range_test.go000066400000000000000000000145621517221076300214420ustar00rootroot00000000000000package vers import "testing" func TestRangeContains(t *testing.T) { tests := []struct { name string r *Range version string want bool }{ { "single interval contains", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, false)}), "1.5.0", true, }, { "single interval excludes below", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, false)}), "0.9.0", false, }, { "single interval excludes above", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, false)}), "2.0.0", false, }, { "multiple intervals (union)", NewRange([]Interval{ NewInterval("1.0.0", "2.0.0", true, true), NewInterval("3.0.0", "4.0.0", true, true), }), "3.5.0", true, }, { "gap between intervals", NewRange([]Interval{ NewInterval("1.0.0", "2.0.0", true, true), NewInterval("3.0.0", "4.0.0", true, true), }), "2.5.0", false, }, { "exclusion", &Range{ Intervals: []Interval{NewInterval("1.0.0", "3.0.0", true, true)}, Exclusions: []string{"2.0.0"}, }, "2.0.0", false, }, { "exclusion allows other versions", &Range{ Intervals: []Interval{NewInterval("1.0.0", "3.0.0", true, true)}, Exclusions: []string{"2.0.0"}, }, "2.1.0", true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.r.Contains(tt.version) if got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestRangeIsEmpty(t *testing.T) { tests := []struct { name string r *Range want bool }{ {"no intervals", &Range{}, true}, {"empty intervals only", NewRange([]Interval{EmptyInterval()}), true}, {"valid interval", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, true)}), false}, {"unbounded", Unbounded(), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.r.IsEmpty() if got != tt.want { t.Errorf("IsEmpty() = %v, want %v", got, tt.want) } }) } } func TestRangeIsUnbounded(t *testing.T) { tests := []struct { name string r *Range want bool }{ {"unbounded", Unbounded(), true}, {"bounded", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, true)}), false}, { "unbounded with exclusion", &Range{ Intervals: []Interval{UnboundedInterval()}, Exclusions: []string{"1.0.0"}, }, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.r.IsUnbounded() if got != tt.want { t.Errorf("IsUnbounded() = %v, want %v", got, tt.want) } }) } } func TestRangeUnion(t *testing.T) { tests := []struct { name string r1 *Range r2 *Range version string want bool }{ { "union of non-overlapping", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, true)}), NewRange([]Interval{NewInterval("3.0.0", "4.0.0", true, true)}), "3.5.0", true, }, { "union of overlapping", NewRange([]Interval{NewInterval("1.0.0", "3.0.0", true, true)}), NewRange([]Interval{NewInterval("2.0.0", "4.0.0", true, true)}), "3.5.0", true, }, { "union preserves first range", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, true)}), NewRange([]Interval{NewInterval("3.0.0", "4.0.0", true, true)}), "1.5.0", true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.r1.Union(tt.r2) got := result.Contains(tt.version) if got != tt.want { t.Errorf("Union().Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestRangeIntersect(t *testing.T) { tests := []struct { name string r1 *Range r2 *Range version string want bool }{ { "intersection of overlapping", NewRange([]Interval{NewInterval("1.0.0", "3.0.0", true, true)}), NewRange([]Interval{NewInterval("2.0.0", "4.0.0", true, true)}), "2.5.0", true, }, { "intersection excludes non-overlap from first", NewRange([]Interval{NewInterval("1.0.0", "3.0.0", true, true)}), NewRange([]Interval{NewInterval("2.0.0", "4.0.0", true, true)}), "1.5.0", false, }, { "intersection excludes non-overlap from second", NewRange([]Interval{NewInterval("1.0.0", "3.0.0", true, true)}), NewRange([]Interval{NewInterval("2.0.0", "4.0.0", true, true)}), "3.5.0", false, }, { "non-overlapping produces empty", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, true)}), NewRange([]Interval{NewInterval("3.0.0", "4.0.0", true, true)}), "1.5.0", false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.r1.Intersect(tt.r2) got := result.Contains(tt.version) if got != tt.want { t.Errorf("Intersect().Contains(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestRangeExclude(t *testing.T) { r := Unbounded().Exclude("1.5.0") if !r.Contains("1.0.0") { t.Error("should contain 1.0.0") } if r.Contains("1.5.0") { t.Error("should not contain excluded 1.5.0") } if !r.Contains("2.0.0") { t.Error("should contain 2.0.0") } } func TestRangeString(t *testing.T) { tests := []struct { name string r *Range want string }{ {"empty", &Range{}, "empty"}, {"unbounded", Unbounded(), "*"}, { "single interval", NewRange([]Interval{NewInterval("1.0.0", "2.0.0", true, false)}), "[1.0.0,2.0.0)", }, { "multiple intervals", NewRange([]Interval{ NewInterval("1.0.0", "2.0.0", true, true), NewInterval("3.0.0", "4.0.0", true, true), }), "[1.0.0,2.0.0] | [3.0.0,4.0.0]", }, { "with exclusion", &Range{ Intervals: []Interval{NewInterval("1.0.0", "3.0.0", true, true)}, Exclusions: []string{"2.0.0"}, }, "[1.0.0,3.0.0] excluding 2.0.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.r.String() if got != tt.want { t.Errorf("String() = %q, want %q", got, tt.want) } }) } } func TestExact(t *testing.T) { r := Exact("1.2.3") if !r.Contains("1.2.3") { t.Error("Exact should contain its version") } if r.Contains("1.2.4") { t.Error("Exact should not contain other versions") } } func TestUnbounded(t *testing.T) { r := Unbounded() if !r.Contains("0.0.0") { t.Error("Unbounded should contain any version") } if !r.Contains("999.999.999") { t.Error("Unbounded should contain any version") } if !r.IsUnbounded() { t.Error("IsUnbounded() should return true") } } golang-github-git-pkgs-vers-0.2.4/vers.go000066400000000000000000000073341517221076300202650ustar00rootroot00000000000000// Package vers provides version range parsing and comparison according to the VERS specification. // // VERS (Version Range Specification) is a universal format for expressing version ranges // across different package ecosystems. This package supports parsing vers URIs, native // package manager syntax, and provides version comparison functionality. // // Quick Start: // // // Parse a vers URI // r, _ := vers.Parse("vers:npm/>=1.2.3|<2.0.0") // r.Contains("1.5.0") // true // // // Parse native package manager syntax // r, _ = vers.ParseNative("^1.2.3", "npm") // // // Check if version satisfies constraint // vers.Satisfies("1.5.0", ">=1.0.0,<2.0.0", "npm") // true // // // Compare versions // vers.Compare("1.2.3", "1.2.4") // -1 // // See https://github.com/package-url/purl-spec/blob/main/VERSION-RANGE-SPEC.rst package vers // Version is the library version. const Version = "0.1.0" // Parse parses a vers URI string into a Range. // // The vers URI format is: vers:/ // For example: vers:npm/>=1.2.3|<2.0.0 // // Use vers:/* for an unbounded range that matches all versions. func Parse(versURI string) (*Range, error) { return defaultParser.Parse(versURI) } // ParseNative parses a native package manager version range into a Range. // // Supported schemes: // - npm: ^1.2.3, ~1.2.3, 1.2.3 - 2.0.0, >=1.0.0 <2.0.0, || // - gem/rubygems: ~> 1.2, >= 1.0, < 2.0 // - pypi: >=1.0,<2.0, ~=1.4.2, !=1.5.0 // - maven: [1.0,2.0), (1.0,2.0], [1.0,) // - nuget: [1.0,2.0), (1.0,2.0] // - cargo: ^1.2.3, ~1.2.3, >=1.0.0, <2.0.0 // - go: >=1.0.0, <2.0.0 // - deb/debian: >= 1.0, << 2.0 // - rpm: >= 1.0, <= 2.0 func ParseNative(constraint string, scheme string) (*Range, error) { return defaultParser.ParseNative(constraint, scheme) } // Satisfies checks if a version satisfies a constraint. // // If scheme is empty, constraint is parsed as a vers URI. // Otherwise, constraint is parsed as native package manager syntax. func Satisfies(version, constraint, scheme string) (bool, error) { var r *Range var err error if scheme == "" { r, err = Parse(constraint) } else { r, err = ParseNative(constraint, scheme) } if err != nil { return false, err } return r.Contains(version), nil } // Compare compares two version strings. // Returns -1 if a < b, 0 if a == b, 1 if a > b. func Compare(a, b string) int { return CompareVersions(a, b) } // Valid checks if a version string is valid. func Valid(version string) bool { _, err := ParseVersion(version) return err == nil } // Normalize normalizes a version string to a consistent format. func Normalize(version string) (string, error) { v, err := ParseVersion(version) if err != nil { return "", err } return v.String(), nil } // Exact creates a range that matches only the specified version. func Exact(version string) *Range { return NewRange([]Interval{ExactInterval(version)}) } // GreaterThan creates a range for versions greater than (or equal to) the specified version. func GreaterThan(version string, inclusive bool) *Range { return NewRange([]Interval{GreaterThanInterval(version, inclusive)}) } // LessThan creates a range for versions less than (or equal to) the specified version. func LessThan(version string, inclusive bool) *Range { return NewRange([]Interval{LessThanInterval(version, inclusive)}) } // Unbounded creates a range that matches all versions. func Unbounded() *Range { return NewRange([]Interval{UnboundedInterval()}) } // Empty creates a range that matches no versions. func Empty() *Range { return NewRange([]Interval{EmptyInterval()}) } // ToVersString converts a Range back to a vers URI string. func ToVersString(r *Range, scheme string) string { return defaultParser.ToVersString(r, scheme) } var defaultParser = NewParser() golang-github-git-pkgs-vers-0.2.4/version.go000066400000000000000000000435651517221076300210010ustar00rootroot00000000000000package vers import ( "fmt" "regexp" "strconv" "strings" "sync" ) // SemanticVersionRegex matches semantic version strings (with optional v prefix). var SemanticVersionRegex = regexp.MustCompile(`^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([^+]+))?(?:\+(.+))?$`) // simpleNumericRegex matches simple numeric versions like "1" or "42". var simpleNumericRegex = regexp.MustCompile(`^\d+$`) // versionCache caches parsed versions to avoid re-parsing the same strings. var versionCache = &boundedCache{ items: make(map[string]*VersionInfo), max: 10000, //nolint:mnd } type boundedCache struct { mu sync.RWMutex items map[string]*VersionInfo max int } func (c *boundedCache) Load(key string) (*VersionInfo, bool) { c.mu.RLock() v, ok := c.items[key] c.mu.RUnlock() return v, ok } func (c *boundedCache) Store(key string, value *VersionInfo) { c.mu.Lock() if len(c.items) >= c.max { c.items = make(map[string]*VersionInfo) } c.items[key] = value c.mu.Unlock() } // VersionInfo represents a parsed version with its components. type VersionInfo struct { Major int Minor int Patch int Prerelease string Build string Original string } // ParseVersion parses a version string into its components. func ParseVersion(s string) (*VersionInfo, error) { if s == "" { return nil, fmt.Errorf("empty version string") } if cached, ok := versionCache.Load(s); ok { return cached, nil } v, err := parseVersionUncached(s) if err != nil { return nil, err } versionCache.Store(s, v) return v, nil } func parseVersionUncached(s string) (*VersionInfo, error) { v := &VersionInfo{Original: s} if simpleNumericRegex.MatchString(s) { v.Major, _ = strconv.Atoi(s) return v, nil } if matches := SemanticVersionRegex.FindStringSubmatch(s); matches != nil { return parseSemverMatches(v, matches), nil } if strings.Contains(s, ".") { return parseDotSeparated(v, s), nil } if strings.Contains(s, "-") { parts := strings.SplitN(s, "-", 2) //nolint:mnd v.Major, _ = strconv.Atoi(parts[0]) if len(parts) > 1 { v.Prerelease = parts[1] } return v, nil } return nil, fmt.Errorf("invalid version format: %s", s) } func parseSemverMatches(v *VersionInfo, matches []string) *VersionInfo { if matches[1] != "" { v.Major, _ = strconv.Atoi(matches[1]) } if matches[2] != "" { v.Minor, _ = strconv.Atoi(matches[2]) } if matches[3] != "" { v.Patch, _ = strconv.Atoi(matches[3]) } v.Prerelease = matches[4] v.Build = matches[5] return v } func parseDotSeparated(v *VersionInfo, s string) *VersionInfo { parts := strings.Split(s, ".") if len(parts) >= 1 { v.Major, _ = strconv.Atoi(parts[0]) } if len(parts) >= 2 && !strings.Contains(parts[1], "-") { //nolint:mnd v.Minor, _ = strconv.Atoi(parts[1]) } if len(parts) >= 3 { //nolint:mnd if strings.Contains(parts[2], "-") { patchParts := strings.SplitN(parts[2], "-", 2) //nolint:mnd v.Patch, _ = strconv.Atoi(patchParts[0]) if len(patchParts) > 1 { v.Prerelease = patchParts[1] } } else { v.Patch, _ = strconv.Atoi(parts[2]) } } if len(parts) > 3 && v.Prerelease == "" { //nolint:mnd v.Prerelease = strings.Join(parts[3:], ".") } return v } // String returns the normalized version string. func (v *VersionInfo) String() string { result := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) if v.Prerelease != "" { result += "-" + v.Prerelease } return result } // Compare compares this version to another. // Returns -1 if v < other, 0 if v == other, 1 if v > other. func (v *VersionInfo) Compare(other *VersionInfo) int { // Compare major if v.Major < other.Major { return -1 } if v.Major > other.Major { return 1 } // Compare minor if v.Minor < other.Minor { return -1 } if v.Minor > other.Minor { return 1 } // Compare patch if v.Patch < other.Patch { return -1 } if v.Patch > other.Patch { return 1 } // Handle prerelease comparison // No prerelease > has prerelease (1.0.0 > 1.0.0-alpha) if v.Prerelease == "" && other.Prerelease != "" { return 1 } if v.Prerelease != "" && other.Prerelease == "" { return -1 } if v.Prerelease == "" && other.Prerelease == "" { return 0 } return comparePrerelease(v.Prerelease, other.Prerelease) } // IsStable returns true if this is a stable release (no prerelease). func (v *VersionInfo) IsStable() bool { return v.Prerelease == "" } // IsPrerelease returns true if this is a prerelease version. func (v *VersionInfo) IsPrerelease() bool { return v.Prerelease != "" } // IncrementMajor returns a new version with major incremented. func (v *VersionInfo) IncrementMajor() *VersionInfo { return &VersionInfo{ Major: v.Major + 1, Minor: 0, Patch: 0, } } // IncrementMinor returns a new version with minor incremented. func (v *VersionInfo) IncrementMinor() *VersionInfo { return &VersionInfo{ Major: v.Major, Minor: v.Minor + 1, Patch: 0, } } // IncrementPatch returns a new version with patch incremented. func (v *VersionInfo) IncrementPatch() *VersionInfo { return &VersionInfo{ Major: v.Major, Minor: v.Minor, Patch: v.Patch + 1, } } // CompareVersions compares two version strings. // Returns -1 if a < b, 0 if a == b, 1 if a > b. func CompareVersions(a, b string) int { if a == b { return 0 } if a == "" { return -1 } if b == "" { return 1 } va, errA := ParseVersion(a) vb, errB := ParseVersion(b) if errA != nil && errB != nil { // Fall back to string comparison if a < b { return -1 } return 1 } if errA != nil { return -1 } if errB != nil { return 1 } return va.Compare(vb) } // CompareWithScheme compares two version strings using scheme-specific rules. // Returns -1 if a < b, 0 if a == b, 1 if a > b. func CompareWithScheme(a, b, scheme string) int { if a == b { return 0 } if a == "" { return -1 } if b == "" { return 1 } switch scheme { case "nuget": //nolint:goconst return compareNuGet(a, b) case "maven": return compareMaven(a, b) default: return CompareVersions(a, b) } } // compareNuGet compares two NuGet version strings. // NuGet uses 4-part versions, trailing zeros are equivalent, and prereleases are case-insensitive. func compareNuGet(a, b string) int { partsA := parseNuGetVersion(a) partsB := parseNuGetVersion(b) // Compare numeric parts (up to 4) for i := 0; i < 4; i++ { if partsA.numeric[i] < partsB.numeric[i] { return -1 } if partsA.numeric[i] > partsB.numeric[i] { return 1 } } // Handle prerelease comparison // No prerelease > has prerelease (1.0.0 > 1.0.0-alpha) if partsA.prerelease == "" && partsB.prerelease != "" { return 1 } if partsA.prerelease != "" && partsB.prerelease == "" { return -1 } if partsA.prerelease == "" && partsB.prerelease == "" { return 0 } // NuGet prerelease comparison is case-insensitive return compareNuGetPrerelease(partsA.prerelease, partsB.prerelease) } type nugetVersion struct { numeric [4]int // major, minor, patch, revision prerelease string } func parseNuGetVersion(s string) nugetVersion { result := nugetVersion{} // Split off build metadata (ignored in comparison) if idx := strings.Index(s, "+"); idx != -1 { s = s[:idx] } // Split off prerelease if idx := strings.Index(s, "-"); idx != -1 { result.prerelease = s[idx+1:] s = s[:idx] } // Parse numeric parts parts := strings.Split(s, ".") for i := 0; i < len(parts) && i < 4; i++ { result.numeric[i], _ = strconv.Atoi(parts[i]) } return result } func compareNuGetPrerelease(a, b string) int { // NuGet prerelease comparison: case-insensitive, dot-separated, numeric parts compared numerically partsA := strings.Split(strings.ToLower(a), ".") partsB := strings.Split(strings.ToLower(b), ".") maxLen := len(partsA) if len(partsB) > maxLen { maxLen = len(partsB) } for i := 0; i < maxLen; i++ { var partA, partB string if i < len(partsA) { partA = partsA[i] } if i < len(partsB) { partB = partsB[i] } if partA == "" { return -1 } if partB == "" { return 1 } // Try numeric comparison numA, errA := strconv.Atoi(partA) numB, errB := strconv.Atoi(partB) if errA == nil && errB == nil { if numA < numB { return -1 } if numA > numB { return 1 } } else { // String comparison (already lowercased) if partA < partB { return -1 } if partA > partB { return 1 } } } return 0 } // compareMaven compares two Maven version strings. // Maven has special qualifier ordering: alpha < beta < milestone < rc < snapshot < "" (release) < sp // Key rules: // - A sublist (afterDash) item is LESS than a direct numeric item (list < int) // - A sublist item is GREATER than a direct string item (list > string) // - Trailing zeros are removed from the base version (before any sublist starts) func compareMaven(a, b string) int { partsA := parseMavenVersion(a) partsB := parseMavenVersion(b) maxLen := len(partsA) if len(partsB) > maxLen { maxLen = len(partsB) } for i := 0; i < maxLen; i++ { var compA, compB mavenComponent if i < len(partsA) { compA = partsA[i] } else { compA = mavenComponent{isNull: true} } if i < len(partsB) { compB = partsB[i] } else { compB = mavenComponent{isNull: true} } cmp := compareMavenComponentsNew(compA, compB) if cmp != 0 { return cmp } } return 0 } // compareMavenComponentsNew compares two Maven components with proper handling of sublist vs direct items. func compareMavenComponentsNew(a, b mavenComponent) int { if a.isNull && b.isNull { return 0 } if a.isNull { return -compareMavenToNull(b) } if b.isNull { return compareMavenToNull(a) } if a.afterDash != b.afterDash { return compareMavenDifferentLevels(a, b) } return compareMavenSameLevel(a, b) } func compareMavenDifferentLevels(a, b mavenComponent) int { if a.afterDash { if b.isNumeric { return -1 // sublist < direct numeric } return 1 // sublist > direct string } if a.isNumeric { return 1 // direct numeric > sublist } return -1 // direct string < sublist } func compareMavenSameLevel(a, b mavenComponent) int { if a.isNumeric && b.isNumeric { return cmpInt(a.numeric, b.numeric) } if a.isNumeric { return 1 // numeric > any qualifier } if b.isNumeric { return -1 // qualifier < numeric } orderA, okA := getMavenQualifierOrder(a.qualifier) orderB, okB := getMavenQualifierOrder(b.qualifier) if orderA != orderB { return cmpInt(orderA, orderB) } if !okA && !okB { return cmpString(a.qualifier, b.qualifier) } return 0 } func cmpInt(a, b int) int { if a < b { return -1 } if a > b { return 1 } return 0 } func cmpString(a, b string) int { if a < b { return -1 } if a > b { return 1 } return 0 } // compareMavenToNull compares a component to null (missing component). func compareMavenToNull(comp mavenComponent) int { if comp.isNumeric { return compareMavenNumericToNull(comp) } return compareMavenQualifierToNull(comp.qualifier) } func compareMavenNumericToNull(comp mavenComponent) int { if comp.numeric == 0 { return 0 } if comp.afterDash && comp.numeric < 0 { return -1 } return 1 } func compareMavenQualifierToNull(qualifier string) int { orderComp, _ := getMavenQualifierOrder(qualifier) orderNull := mavenQualifierOrder[""] return cmpInt(orderComp, orderNull) } type mavenComponent struct { isNumeric bool numeric int qualifier string isNull bool afterDash bool // true if this component came after a dash (or digit-letter transition) } // Maven qualifier ordering // Order: alpha < beta < milestone < rc < snapshot < "" (release) < sp < unknown < numbers // //nolint:mnd var mavenQualifierOrder = map[string]int{ "alpha": 1, "beta": 2, "milestone": 3, "rc": 4, "snapshot": 5, "": 6, // release "sp": 7, // sp comes after release but before unknown qualifiers } func getMavenQualifierOrder(q string) (int, bool) { order, ok := mavenQualifierOrder[strings.ToLower(q)] if ok { return order, true } return 8, false //nolint:mnd } func parseMavenVersion(s string) []mavenComponent { var result []mavenComponent // Maven versions are split by . and - AND on transitions between digits and letters s = strings.ToLower(s) // Split on delimiters and digit/letter transitions, tracking separators parts, afterDashFlags := splitMavenVersionWithSeparators(s) for i, part := range parts { if part == "" { continue } // Check if next part is a digit (for single-letter qualifier normalization) nextIsDigit := false if i+1 < len(parts) { if _, err := strconv.Atoi(parts[i+1]); err == nil { nextIsDigit = true } } // Normalize qualifier aliases normalized := normalizeMavenQualifierWithNext(part, nextIsDigit) if normalized == "" { // Skip empty qualifiers (ga, final, release are equivalent to nothing) continue } afterDash := false if i < len(afterDashFlags) { afterDash = afterDashFlags[i] } if num, err := strconv.Atoi(normalized); err == nil { result = append(result, mavenComponent{isNumeric: true, numeric: num, afterDash: afterDash}) } else { result = append(result, mavenComponent{qualifier: normalized, afterDash: afterDash}) } } // Maven normalization: remove trailing null-equivalent components // A null component is: 0 for numeric, "" for string (already handled above) // Also remove trailing zeros BEFORE the first qualifier (if any) result = normalizeMavenComponents(result) return result } // normalizeMavenComponents removes trailing null-equivalent components. // In Maven, trailing zeros are removed from each "level": // - From the base version (before any sublist) // - From the end of the version // This makes "1.0-a" normalize to [1, a] (the 0 is trailing in the base) func normalizeMavenComponents(components []mavenComponent) []mavenComponent { if len(components) == 0 { return components } // Find the first sublist component (afterDash=true) firstSublistIdx := -1 for i, c := range components { if c.afterDash { firstSublistIdx = i break } } // Remove trailing zeros from the base portion (before first sublist) if firstSublistIdx > 0 { // Trim trailing zeros from base (indices 0 to firstSublistIdx-1) baseEnd := firstSublistIdx for baseEnd > 1 && components[baseEnd-1].isNumeric && components[baseEnd-1].numeric == 0 { baseEnd-- } if baseEnd < firstSublistIdx { // Rebuild: base without trailing zeros + sublist portion newComponents := make([]mavenComponent, baseEnd) copy(newComponents, components[:baseEnd]) newComponents = append(newComponents, components[firstSublistIdx:]...) components = newComponents } } else if firstSublistIdx == -1 { // No sublist - just remove trailing zeros from the end for len(components) > 0 { last := components[len(components)-1] if last.isNumeric && last.numeric == 0 { components = components[:len(components)-1] } else { break } } } // If firstSublistIdx == 0, the first component is a sublist, nothing to trim from base return components } // normalizeMavenQualifier normalizes Maven qualifier aliases // In Maven, single-letter qualifiers (a, b, m) only become their full forms when followed by a digit // This is handled by normalizeMavenQualifierWithNext func normalizeMavenQualifier(q string) string { switch q { case "cr": return "rc" case "ga", "final", "release": return "" // These are equivalent to release (no qualifier) default: return q } } // normalizeMavenQualifierWithNext normalizes a qualifier considering the next part func normalizeMavenQualifierWithNext(q string, nextIsDigit bool) string { // Single-letter qualifiers become their full form only when followed by a digit if nextIsDigit && len(q) == 1 { switch q { case "a": return "alpha" case "b": return "beta" case "m": return "milestone" } } return normalizeMavenQualifier(q) } // splitMavenVersionWithSeparators splits a Maven version string and tracks which parts came after a dash. // Returns the parts and a parallel slice of booleans indicating if each part is "after dash" (sublist). // Both '-' separator AND digit-letter transitions create sublist context. func splitMavenVersionWithSeparators(s string) ([]string, []bool) { var parts []string var afterDash []bool var current strings.Builder var lastWasDigit bool firstChar := true currentAfterDash := false // first component is never after dash for _, c := range s { if c == '.' || c == '-' { if current.Len() > 0 { parts = append(parts, current.String()) afterDash = append(afterDash, currentAfterDash) current.Reset() } // '-' creates afterDash context (sublist in Maven's model) // '.' does NOT create afterDash context currentAfterDash = (c == '-') firstChar = true continue } isDigit := c >= '0' && c <= '9' // Split on digit/letter transitions (but not at the start) // In Maven, digit-letter transitions also create sublist context if !firstChar && isDigit != lastWasDigit && current.Len() > 0 { parts = append(parts, current.String()) afterDash = append(afterDash, currentAfterDash) current.Reset() // Digit-letter transition creates sublist for the new component currentAfterDash = true } current.WriteRune(c) lastWasDigit = isDigit firstChar = false } if current.Len() > 0 { parts = append(parts, current.String()) afterDash = append(afterDash, currentAfterDash) } return parts, afterDash } func comparePrerelease(a, b string) int { partsA := strings.Split(a, ".") partsB := strings.Split(b, ".") maxLen := len(partsA) if len(partsB) > maxLen { maxLen = len(partsB) } for i := 0; i < maxLen; i++ { var partA, partB string if i < len(partsA) { partA = partsA[i] } if i < len(partsB) { partB = partsB[i] } if partA == "" { return -1 } if partB == "" { return 1 } // Try numeric comparison numA, errA := strconv.Atoi(partA) numB, errB := strconv.Atoi(partB) if errA == nil && errB == nil { if numA < numB { return -1 } if numA > numB { return 1 } } else { // String comparison if partA < partB { return -1 } if partA > partB { return 1 } } } return 0 } golang-github-git-pkgs-vers-0.2.4/version_test.go000066400000000000000000000064761517221076300220400ustar00rootroot00000000000000package vers import "testing" func TestParseVersion(t *testing.T) { tests := []struct { input string major int minor int patch int prerel string wantErr bool }{ {"1", 1, 0, 0, "", false}, {"1.2", 1, 2, 0, "", false}, {"1.2.3", 1, 2, 3, "", false}, {"1.2.3-alpha", 1, 2, 3, "alpha", false}, {"1.2.3-alpha.1", 1, 2, 3, "alpha.1", false}, {"1.2.3-beta.2+build.123", 1, 2, 3, "beta.2", false}, {"0.0.1", 0, 0, 1, "", false}, {"10.20.30", 10, 20, 30, "", false}, {"", 0, 0, 0, "", true}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { v, err := ParseVersion(tt.input) if (err != nil) != tt.wantErr { t.Errorf("ParseVersion(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) return } if err != nil { return } if v.Major != tt.major { t.Errorf("Major = %d, want %d", v.Major, tt.major) } if v.Minor != tt.minor { t.Errorf("Minor = %d, want %d", v.Minor, tt.minor) } if v.Patch != tt.patch { t.Errorf("Patch = %d, want %d", v.Patch, tt.patch) } if v.Prerelease != tt.prerel { t.Errorf("Prerelease = %q, want %q", v.Prerelease, tt.prerel) } }) } } func TestCompareVersions(t *testing.T) { tests := []struct { a, b string want int }{ // Equal versions {"1.0.0", "1.0.0", 0}, {"1.2.3", "1.2.3", 0}, // Major version differences {"1.0.0", "2.0.0", -1}, {"2.0.0", "1.0.0", 1}, // Minor version differences {"1.1.0", "1.2.0", -1}, {"1.2.0", "1.1.0", 1}, // Patch version differences {"1.0.1", "1.0.2", -1}, {"1.0.2", "1.0.1", 1}, // Prerelease vs stable (stable > prerelease) {"1.0.0", "1.0.0-alpha", 1}, {"1.0.0-alpha", "1.0.0", -1}, // Prerelease comparison {"1.0.0-alpha", "1.0.0-beta", -1}, {"1.0.0-beta", "1.0.0-alpha", 1}, {"1.0.0-alpha.1", "1.0.0-alpha.2", -1}, // Different lengths {"1", "1.0.0", 0}, {"1.2", "1.2.0", 0}, } for _, tt := range tests { t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { got := CompareVersions(tt.a, tt.b) if got != tt.want { t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) } }) } } func TestVersionString(t *testing.T) { tests := []struct { input string want string }{ {"1.2.3", "1.2.3"}, {"1.2.3-alpha", "1.2.3-alpha"}, {"1", "1.0.0"}, {"1.2", "1.2.0"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { v, err := ParseVersion(tt.input) if err != nil { t.Fatalf("ParseVersion(%q) error = %v", tt.input, err) } got := v.String() if got != tt.want { t.Errorf("String() = %q, want %q", got, tt.want) } }) } } func TestVersionIncrement(t *testing.T) { v, _ := ParseVersion("1.2.3") major := v.IncrementMajor() if major.String() != "2.0.0" { t.Errorf("IncrementMajor() = %q, want %q", major.String(), "2.0.0") } minor := v.IncrementMinor() if minor.String() != "1.3.0" { t.Errorf("IncrementMinor() = %q, want %q", minor.String(), "1.3.0") } patch := v.IncrementPatch() if patch.String() != "1.2.4" { t.Errorf("IncrementPatch() = %q, want %q", patch.String(), "1.2.4") } } func TestVersionIsStable(t *testing.T) { stable, _ := ParseVersion("1.2.3") if !stable.IsStable() { t.Error("1.2.3 should be stable") } prerelease, _ := ParseVersion("1.2.3-alpha") if prerelease.IsStable() { t.Error("1.2.3-alpha should not be stable") } }