diff --git a/bazel.go b/bazel.go new file mode 100644 index 0000000..463b748 --- /dev/null +++ b/bazel.go @@ -0,0 +1,130 @@ +package vers + +import ( + "regexp" + "strconv" + "strings" +) + +var bazelVersionRegex = regexp.MustCompile(`^([A-Za-z0-9.]+)(-([A-Za-z0-9.-]+))?(\+([A-Za-z0-9.-]+))?$`) + +type bazelVersion struct { + release []bazelIdentifier + prerelease []bazelIdentifier + normalized string + empty bool +} + +type bazelIdentifier struct { + text string + number uint64 + numeric bool +} + +func parseBazelVersion(version string) (bazelVersion, bool) { + if version == "" { + return bazelVersion{empty: true}, true + } + + match := bazelVersionRegex.FindStringSubmatch(version) + if match == nil { + return bazelVersion{}, false + } + + release, ok := parseBazelIdentifiers(match[1], true) + if !ok { + return bazelVersion{}, false + } + prerelease, ok := parseBazelIdentifiers(match[3], true) + if !ok { + return bazelVersion{}, false + } + if _, ok := parseBazelIdentifiers(match[5], false); !ok { + return bazelVersion{}, false + } + + normalized := match[1] + if match[3] != "" { + normalized += "-" + match[3] + } + return bazelVersion{release: release, prerelease: prerelease, normalized: normalized}, true +} + +func parseBazelIdentifiers(value string, numericLimit bool) ([]bazelIdentifier, bool) { + if value == "" { + return nil, true + } + + parts := strings.Split(value, ".") + identifiers := make([]bazelIdentifier, 0, len(parts)) + for _, part := range parts { + if part == "" { + return nil, false + } + identifier := bazelIdentifier{text: part, numeric: isDigits(part)} + if identifier.numeric && numericLimit { + number, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return nil, false + } + identifier.number = number + } + identifiers = append(identifiers, identifier) + } + return identifiers, true +} + +func compareBazel(a, b string) int { + left, leftOK := parseBazelVersion(a) + right, rightOK := parseBazelVersion(b) + if !leftOK || !rightOK { + return cmpString(a, b) + } + if left.empty { + if right.empty { + return 0 + } + return 1 + } + if right.empty { + return -1 + } + if result := compareBazelIdentifiers(left.release, right.release); result != 0 { + return result + } + if len(left.prerelease) == 0 && len(right.prerelease) != 0 { + return 1 + } + if len(left.prerelease) != 0 && len(right.prerelease) == 0 { + return -1 + } + return compareBazelIdentifiers(left.prerelease, right.prerelease) +} + +func compareBazelIdentifiers(a, b []bazelIdentifier) int { + limit := min(len(a), len(b)) + for i := 0; i < limit; i++ { + if result := compareBazelIdentifier(a[i], b[i]); result != 0 { + return result + } + } + return cmpInt(len(a), len(b)) +} + +func compareBazelIdentifier(a, b bazelIdentifier) int { + if a.numeric != b.numeric { + if a.numeric { + return -1 + } + return 1 + } + if a.numeric { + if a.number < b.number { + return -1 + } + if a.number > b.number { + return 1 + } + } + return cmpString(a.text, b.text) +} diff --git a/bazel_test.go b/bazel_test.go new file mode 100644 index 0000000..f5aeb52 --- /dev/null +++ b/bazel_test.go @@ -0,0 +1,194 @@ +package vers + +import "testing" + +func TestBazelComparisonThroughPublicAPI(t *testing.T) { + tests := []struct { + left, right string + want int + }{ + {left: "0.7.1", right: "0.7.1.bcr.1", want: -1}, + {left: "0.7.1.bcr.2", right: "0.7.1.bcr.10", want: -1}, + {left: "36.0-rc2", right: "36.0", want: -1}, + {left: "36.0", right: "36.0.bcr.1", want: -1}, + {left: "2.0", right: "1.0", want: 1}, + {left: "2.0", right: "1.9", want: 1}, + {left: "11.0", right: "3.0", want: 1}, + {left: "1.0.1", right: "1.0", want: 1}, + {left: "1.0.0", right: "1.0", want: 1}, + {left: "1.0", right: "1.0-pre", want: 1}, + {left: "1.0.patch.3", right: "1.0", want: 1}, + {left: "1.0.patch.3", right: "1.0.patch.2", want: 1}, + {left: "1.0.patch.3", right: "1.0.patch.10", want: -1}, + {left: "1.0.patch3", right: "1.0.patch10", want: 1}, + {left: "4", right: "a", want: -1}, + {left: "abc", right: "abd", want: -1}, + {left: "1.0-pre", right: "1.0-are", want: 1}, + {left: "1.0-3", right: "1.0-2", want: 1}, + {left: "1.0-pre", right: "1.0-pre.foo", want: -1}, + {left: "1.0-pre.3", right: "1.0-pre.2", want: 1}, + {left: "1.0-pre.10", right: "1.0-pre.2", want: 1}, + {left: "1.0-pre.10a", right: "1.0-pre.2a", want: -1}, + {left: "1.0-pre.99", right: "1.0-pre.2a", want: -1}, + {left: "1.0-pre.patch.3", right: "1.0-pre.patch.4", want: -1}, + {left: "1.0--", right: "1.0----", want: -1}, + {left: "2.1.1-develop.bcr.20250113215904", right: "2.1.1-develop.bcr.20250113215903", want: 1}, + {left: "1.0+build2", right: "1.0+build3", want: 0}, + {left: "1.0", right: "1.0+build-notpre", want: 0}, + {left: "01", right: "1", want: -1}, + {left: "", right: "1.0", want: 1}, + {left: "", right: "1.0-pre+build-kek.lol", want: 1}, + } + + for _, test := range tests { + if got := CompareWithScheme(test.left, test.right, "bazel"); got != test.want { + t.Errorf("CompareWithScheme(%q, %q, bazel) = %d, want %d", test.left, test.right, got, test.want) + } + } + + if got := Compare("0.7.1", "0.7.1.bcr.1"); got <= 0 { + t.Errorf("generic Compare() = %d, want existing generic ordering", got) + } +} + +func TestBazelValidationAndNormalizationThroughPublicAPI(t *testing.T) { + valid := []string{ + "35.1", + "0.7.1.bcr.1", + "20210324.2", + "1.0.patch.3", + "1.0--", + "1.0-pre+build-kek.lol", + "01", + "v1.0", + "18446744073709551615", + } + for _, version := range valid { + if !ValidWithScheme(version, "bazel") { + t.Errorf("ValidWithScheme(%q, bazel) = false", version) + } + } + + invalid := []string{ + "", + "-abc", + "1_2", + "ßážëł", + "1.0-pre?", + "18446744073709551616", + "1.0-18446744073709551616", + "1.0-pre///", + "1..0", + "1.0-pre..erp", + "1.0+build..metadata", + " 1.0", + } + for _, version := range invalid { + if ValidWithScheme(version, "bazel") { + t.Errorf("ValidWithScheme(%q, bazel) = true", version) + } + } + + normalizations := map[string]string{ + "35.1": "35.1", + "0.7.1.bcr.1": "0.7.1.bcr.1", + "1.0.patch.3": "1.0.patch.3", + "1.0-pre+build.1": "1.0-pre", + "v20210324.2+build.1": "v20210324.2", + } + for input, want := range normalizations { + normalized, err := NormalizeWithScheme(input, "bazel") + if err != nil { + t.Fatal(err) + } + if normalized != want { + t.Errorf("NormalizeWithScheme(%q, bazel) = %q, want %q", input, normalized, want) + } + } + + if _, err := NormalizeWithScheme("1..0", "bazel"); err == nil { + t.Error("NormalizeWithScheme accepted an invalid Bazel version") + } +} + +func TestBazelClassificationThroughPublicAPI(t *testing.T) { + for _, version := range []string{"35.1", "0.7.1.bcr.1"} { + if !IsStableWithScheme(version, "bazel") { + t.Errorf("IsStableWithScheme(%q, bazel) = false", version) + } + if IsPrereleaseWithScheme(version, "bazel") { + t.Errorf("IsPrereleaseWithScheme(%q, bazel) = true", version) + } + } + + if IsStableWithScheme("36.0-rc2", "bazel") { + t.Error("IsStableWithScheme(36.0-rc2, bazel) = true") + } + if !IsPrereleaseWithScheme("36.0-rc2", "bazel") { + t.Error("IsPrereleaseWithScheme(36.0-rc2, bazel) = false") + } + if !IsPrereleaseWithScheme("36.0-rc2.bcr.1", "bazel") { + t.Error("IsPrereleaseWithScheme(36.0-rc2.bcr.1, bazel) = false") + } + if IsStableWithScheme("1..0", "bazel") || IsPrereleaseWithScheme("1..0", "bazel") { + t.Error("invalid Bazel version was classified") + } +} + +func TestBazelRangesThroughPublicAPI(t *testing.T) { + r, err := Parse("vers:bazel/>=36.0") + if err != nil { + t.Fatal(err) + } + if !r.Contains("36.0.bcr.1") { + t.Error("Parse Bazel range does not contain 36.0.bcr.1") + } + if r.Contains("36..0") { + t.Error("Bazel range contains an invalid version") + } + + native, err := ParseNative(">=36.0", "bazel") + if err != nil { + t.Fatal(err) + } + if !native.Contains("36.0.bcr.1") { + t.Error("ParseNative Bazel range does not contain 36.0.bcr.1") + } + bounded, err := ParseNative(">=36.0|<36.0.bcr.2", "bazel") + if err != nil { + t.Fatal(err) + } + if !bounded.Contains("36.0.bcr.1") || bounded.Contains("36.0.bcr.2") { + t.Error("ParseNative Bazel range did not apply both constraints") + } + + satisfies, err := Satisfies("36.0.bcr.1", ">=36.0", "bazel") + if err != nil { + t.Fatal(err) + } + if !satisfies { + t.Error("Satisfies returned false for 36.0.bcr.1 >= 36.0") + } + + vPrefixed, err := Parse("vers:bazel/>=v36.0") + if err != nil { + t.Fatal(err) + } + if len(vPrefixed.Intervals) != 1 || vPrefixed.Intervals[0].Min != "v36.0" { + t.Errorf("Parse did not preserve Bazel release text: %#v", vPrefixed.Intervals) + } + if _, err := ParseNative(">=36..0", "bazel"); err == nil { + t.Error("ParseNative accepted an invalid Bazel constraint version") + } +} + +func TestHighestSatisfyingBazelThroughPublicAPI(t *testing.T) { + versions := []string{"36.0-rc2", "35.1", "36.0", "invalid_version", "36.0.bcr.1"} + got, err := HighestSatisfying(versions, ">=35.1", "bazel") + if err != nil { + t.Fatal(err) + } + if got != "36.0.bcr.1" { + t.Errorf("HighestSatisfying() = %q, want 36.0.bcr.1", got) + } +} diff --git a/constraint.go b/constraint.go index 7665938..928e5a6 100644 --- a/constraint.go +++ b/constraint.go @@ -29,15 +29,14 @@ func ParseConstraintWithScheme(s, scheme string) (*Constraint, error) { } // parseConstraintWithScheme parses a constraint with scheme-specific handling. -// For Go/golang schemes, the v prefix is preserved. +// For Go/golang and Bazel 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 == schemeGo || scheme == schemeGolang + preserveVPrefix := scheme == schemeGo || scheme == schemeGolang || scheme == schemeBazel operator := constraintOperator(s) if operator != "" { diff --git a/normalization.go b/normalization.go index cacb4fb..911f308 100644 --- a/normalization.go +++ b/normalization.go @@ -13,6 +13,10 @@ var ( ) func validVersionForScheme(version, scheme string) bool { //nolint:gocyclo + if scheme == schemeBazel { + _, ok := parseBazelVersion(version) + return ok && version != "" + } version = strings.TrimSpace(version) if version == "" { return false @@ -49,6 +53,13 @@ func validVersionForScheme(version, scheme string) bool { //nolint:gocyclo } func normalizeVersionForScheme(version, scheme string) (string, error) { + if scheme == schemeBazel { + parsed, ok := parseBazelVersion(version) + if !ok || version == "" { + return "", fmt.Errorf("invalid %s version: %s", scheme, version) + } + return parsed.normalized, nil + } version = strings.TrimSpace(version) if scheme == "" { return Normalize(version) diff --git a/parser.go b/parser.go index e62eb0f..e52e83a 100644 --- a/parser.go +++ b/parser.go @@ -317,6 +317,9 @@ func (p *Parser) parseConstraints(constraintsStr, scheme string) (*Range, error) if err != nil { return nil, err } + if scheme == schemeBazel && !validVersionForScheme(constraint.Version, scheme) { + return nil, fmt.Errorf("invalid %s version: %s", scheme, constraint.Version) + } if constraint.IsExclusion() { exclusions = append(exclusions, constraint.Version) diff --git a/range.go b/range.go index 9d15f51..8f19b13 100644 --- a/range.go +++ b/range.go @@ -27,6 +27,9 @@ func NewRange(intervals []Interval) *Range { func (r *Range) Contains(version string) bool { scheme := canonicalScheme(r.Scheme) cmp := compareFuncFor(r.Scheme) + if scheme == schemeBazel && !validVersionForScheme(version, scheme) { + return false + } if scheme == schemeCargo { cmp = compareSemver } diff --git a/schemes_test.go b/schemes_test.go index a9f9268..be31ba9 100644 --- a/schemes_test.go +++ b/schemes_test.go @@ -349,6 +349,41 @@ func TestSchemeAwareValidationAndNormalization(t *testing.T) { if _, err := NormalizeWithScheme("x:1.0", "deb"); err == nil { t.Error("NormalizeWithScheme accepted an invalid Debian version") } + if IsStableWithScheme("1.2.3.4", "npm") || IsPrereleaseWithScheme("1.2.3.4", "npm") { + t.Error("scheme-aware classification accepted an invalid npm version") + } + + classifications := []struct { + scheme, version string + prerelease bool + }{ + {scheme: "pypi", version: "1.0.post1"}, + {scheme: "pypi", version: "1.0.dev1", prerelease: true}, + {scheme: "deb", version: "1.0-1"}, + {scheme: "deb", version: "1.0~rc1-1", prerelease: true}, + {scheme: "rpm", version: "1.0-1"}, + {scheme: "rpm", version: "1.0~rc1-1", prerelease: true}, + {scheme: "nuget", version: "1.2.3.4"}, + {scheme: "nuget", version: "1.2.3-alpha", prerelease: true}, + {scheme: "composer", version: "1.0-p1"}, + {scheme: "composer", version: "dev-main", prerelease: true}, + {scheme: "maven", version: "1.0-sp1"}, + {scheme: "maven", version: "1.0-rc1", prerelease: true}, + {scheme: "openssl", version: "1.1.1a"}, + {scheme: "openssl", version: "3.0.0-alpha1", prerelease: true}, + {scheme: "gentoo", version: "1.0_p1"}, + {scheme: "gentoo", version: "1.0_rc1", prerelease: true}, + {scheme: "conan", version: "1.0+build1"}, + {scheme: "conan", version: "1.0-alpha", prerelease: true}, + } + for _, tt := range classifications { + if got := IsPrereleaseWithScheme(tt.version, tt.scheme); got != tt.prerelease { + t.Errorf("IsPrereleaseWithScheme(%q, %q) = %v, want %v", tt.version, tt.scheme, got, tt.prerelease) + } + if got := IsStableWithScheme(tt.version, tt.scheme); got == tt.prerelease { + t.Errorf("IsStableWithScheme(%q, %q) = %v, want %v", tt.version, tt.scheme, got, !tt.prerelease) + } + } } func TestNativeRangeSchemeEdges(t *testing.T) { diff --git a/vers.go b/vers.go index c811d81..5bd2f2e 100644 --- a/vers.go +++ b/vers.go @@ -22,6 +22,8 @@ // See https://github.com/package-url/purl-spec/blob/main/VERSION-RANGE-SPEC.rst package vers +import "strings" + // Version is the library version. const Version = "0.6.0" @@ -38,6 +40,7 @@ func Parse(versURI string) (*Range, error) { // ParseNative parses a native package manager version range into a Range. // // Supported schemes: +// - bazel: >=1.0|<2.0 // - npm: ^1.2.3, ~1.2.3, 1.2.3 - 2.0.0, >=1.0.0 <2.0.0, || // - composer: ^1.2.3, ~1.2, 1.2.*, >=1.0 <2.0, || // - gem/rubygems: ~> 1.2, >= 1.0, < 2.0 @@ -133,6 +136,97 @@ func Valid(version string) bool { return err == nil } +// IsStableWithScheme checks whether a valid version has no prerelease part. +func IsStableWithScheme(version, scheme string) bool { + valid, prerelease := classifyVersionWithScheme(version, scheme) + return valid && !prerelease +} + +// IsPrereleaseWithScheme checks whether a valid version has a prerelease part. +func IsPrereleaseWithScheme(version, scheme string) bool { + valid, prerelease := classifyVersionWithScheme(version, scheme) + return valid && prerelease +} + +func classifyVersionWithScheme(version, scheme string) (bool, bool) { + if !validVersionForScheme(version, scheme) { + return false, false + } + version = strings.TrimSpace(version) + + switch canonicalScheme(scheme) { + case schemeBazel: + parsed, ok := parseBazelVersion(version) + return ok, len(parsed.prerelease) != 0 + case schemePyPI: + parsed, ok := parsePEP440(version) + return ok, parsed.hasPre || parsed.hasDev + case schemeComposer: + if isComposerBranchVersion(version) || composerNumericBranchRegex.MatchString(version) { + return true, true + } + parsed, ok := parseComposerVersion(version) + return ok, parsed.stability < composerStabilityStable + case schemeNuGet: + return true, parseNuGetVersion(version).prerelease != "" + case schemeDeb: + _, upstream, revision := splitDebianVersion(version) + return true, strings.Contains(upstream, "~") || strings.Contains(revision, "~") + case schemeRPM: + _, releaseVersion, release := splitRPMVersion(version) + return true, strings.Contains(releaseVersion, "~") || strings.Contains(release, "~") + case schemeMaven: + return true, mavenVersionIsPrerelease(version) + case schemeOpenSSL: + parsed, ok := parseOpenSSLVersion(version) + if !ok { + return false, false + } + if cmpNumStr(parsed.core[0], "3") >= 0 { + return true, strings.HasPrefix(parsed.patch, "-") + } + return true, strings.HasPrefix(parsed.patch, "-alpha") || strings.HasPrefix(parsed.patch, "-beta") + case schemeGentoo, schemeAPK: + return true, gentooVersionIsPrerelease(version) + case schemeConan: + _, _, prerelease, _, _ := splitConanVersion(version) + return true, prerelease + case schemeLexicographic, schemeDatetime, schemeIntDot, schemeNginx, schemeALPM: + return true, false + } + + parsed, err := ParseVersion(version) + return err == nil, err == nil && parsed.IsPrerelease() +} + +func mavenVersionIsPrerelease(version string) bool { + releaseOrder := mavenQualifierOrder[""] + for _, component := range parseMavenVersion(version) { + if component.isNumeric { + continue + } + order, _ := getMavenQualifierOrder(component.qualifier) + if order < releaseOrder { + return true + } + } + return false +} + +func gentooVersionIsPrerelease(version string) bool { + version, _ = splitGentooRevision(version) + _, suffixes, more := splitGentooBase(version) + for more { + suffix, rest, hasMore := nextGentooSuffix(suffixes) + kind, _ := parseGentooSuffix(suffix) + if gentooSuffixRank(kind) < 0 { + return true + } + suffixes, more = rest, hasMore + } + return false +} + // Normalize normalizes a version string to a consistent format. func Normalize(version string) (string, error) { v, err := ParseVersion(version) diff --git a/version.go b/version.go index 4528eb3..080f52c 100644 --- a/version.go +++ b/version.go @@ -13,6 +13,7 @@ const ( schemeALPM = "alpm" schemeAlpine = "alpine" schemeAPK = "apk" + schemeBazel = "bazel" schemeCargo = "cargo" schemeComposer = "composer" schemeConan = "conan" @@ -330,7 +331,7 @@ func CompareWithScheme(a, b, scheme string) int { if a == b { return 0 } - if scheme == schemeGem || scheme == schemeRubyGems || scheme == schemeGo || scheme == schemeGolang { + if scheme == schemeBazel || scheme == schemeGem || scheme == schemeRubyGems || scheme == schemeGo || scheme == schemeGolang { return compareFuncFor(scheme)(a, b) } if a == "" { @@ -346,6 +347,8 @@ func CompareWithScheme(a, b, scheme string) int { // compareFuncFor returns the version comparison function for a scheme. func compareFuncFor(scheme string) func(a, b string) int { switch scheme { + case schemeBazel: + return compareBazel case schemeSemVer, schemeHex, schemeElixir, schemeNginx: return compareSemver case schemeNPM: