From 81011039d9ebdbe6d4e386867339f40804f795af Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Wed, 19 Aug 2026 16:39:35 +0530 Subject: [PATCH 1/2] Remove allocations from ALPM, Conan and Gentoo comparators --- bench_test.go | 24 +++ ecosystem_extra.go | 266 +++++++++++++++----------- ecosystem_extra_parity_test.go | 145 ++++++++++++++ ecosystem_extra_reference_test.go | 305 ++++++++++++++++++++++++++++++ schemes.go | 9 +- 5 files changed, 637 insertions(+), 112 deletions(-) create mode 100644 ecosystem_extra_parity_test.go create mode 100644 ecosystem_extra_reference_test.go diff --git a/bench_test.go b/bench_test.go index 90afb2e..e1528d6 100644 --- a/bench_test.go +++ b/bench_test.go @@ -125,6 +125,30 @@ func BenchmarkCompareWithScheme(b *testing.B) { } } +// BenchmarkCompareEcosystem covers the scheme specific comparators that do not +// go through the shared SemVer path, so allocation regressions in them show up +// directly. +func BenchmarkCompareEcosystem(b *testing.B) { + tests := []struct { + name, a, other, scheme string + }{ + {name: "ALPM", a: "1.2.3-1", other: "1.2.4-1", scheme: "alpm"}, + {name: "ALPM_Epoch", a: "1:1.2.3-1", other: "2:1.2.3-1", scheme: "alpm"}, + {name: "Conan", a: "1.2.3-rc1", other: "1.2.3", scheme: "conan"}, + {name: "Conan_Build", a: "1.2.0-alpha+build1", other: "1.2-alpha+build2", scheme: "conan"}, + {name: "Gentoo", a: "1.2.3_rc1", other: "1.2.3", scheme: "gentoo"}, + {name: "Gentoo_Revision", a: "1.2.3a_p1-r2", other: "1.2.3a_p2-r1", scheme: "gentoo"}, + {name: "APK", a: "1.2.3_rc1", other: "1.2.3", scheme: "apk"}, + } + for _, tt := range tests { + b.Run(tt.name, func(b *testing.B) { + for b.Loop() { + CompareWithScheme(tt.a, tt.other, tt.scheme) + } + }) + } +} + func BenchmarkParseConstraint(b *testing.B) { for b.Loop() { _, _ = ParseConstraint(">=1.2.3") diff --git a/ecosystem_extra.go b/ecosystem_extra.go index f1550c9..7e35189 100644 --- a/ecosystem_extra.go +++ b/ecosystem_extra.go @@ -39,19 +39,19 @@ func splitALPMVersion(s string) (epoch, version, release string, hasRelease bool return epoch, version, release, hasRelease } -func splitTypedSegments(s string) []typedSegment { +// nextTypedSegment splits the leading run of same-kind bytes off s. ok is +// false once s is exhausted, which is how an empty part reports that it has no +// segments at all. +func nextTypedSegment(s string) (seg typedSegment, rest string, ok bool) { if s == "" { - return nil - } - segments := make([]typedSegment, 0) - start, kind := 0, segmentKind(s[0]) - for i := 1; i < len(s); i++ { - if next := segmentKind(s[i]); next != kind { - segments = append(segments, typedSegment{value: s[start:i], kind: kind}) - start, kind = i, next - } + return typedSegment{}, "", false + } + kind := segmentKind(s[0]) + i := 1 + for i < len(s) && segmentKind(s[i]) == kind { + i++ } - return append(segments, typedSegment{value: s[start:], kind: kind}) + return typedSegment{value: s[:i], kind: kind}, s[i:], true } func segmentKind(c byte) int { @@ -65,125 +65,139 @@ func segmentKind(c byte) int { } func compareALPMPart(a, b string) int { - pa, pb := splitTypedSegments(a), splitTypedSegments(b) - for i := 0; i < len(pa) || i < len(pb); i++ { - if i >= len(pa) { - if pb[i].kind == segmentAlpha { + for { + sa, restA, okA := nextTypedSegment(a) + sb, restB, okB := nextTypedSegment(b) + if !okA && !okB { + return 0 + } + if !okA { + if sb.kind == segmentAlpha { return 1 } return -1 } - if i >= len(pb) { - if pa[i].kind == segmentAlpha { + if !okB { + if sa.kind == segmentAlpha { return -1 } return 1 } - a, b := pa[i], pb[i] - if a.kind != b.kind { - if a.kind == segmentDigit { + if sa.kind != sb.kind { + if sa.kind == segmentDigit { return 1 } - if b.kind == segmentDigit { + if sb.kind == segmentDigit { return -1 } - if a.kind == segmentOther { + if sa.kind == segmentOther { return 1 } return -1 } var c int - switch a.kind { + switch sa.kind { case segmentDigit: - c = cmpNumStr(a.value, b.value) + c = cmpNumStr(sa.value, sb.value) case segmentAlpha: - c = cmpString(a.value, b.value) + c = cmpString(sa.value, sb.value) default: - c = cmpInt(len(a.value), len(b.value)) + c = cmpInt(len(sa.value), len(sb.value)) } if c != 0 { return c } + a, b = restA, restB } - return 0 -} - -type conanVersion struct { - main []conanItem - pre *conanVersion - build *conanVersion -} - -type conanItem struct { - value string - num bool } func compareConan(a, b string) int { - return compareConanVersion(parseConanVersion(a), parseConanVersion(b)) -} + mainA, preA, hasPreA, buildA, hasBuildA := splitConanVersion(a) + mainB, preB, hasPreB, buildB, hasBuildB := splitConanVersion(b) -func parseConanVersion(s string) conanVersion { - v := conanVersion{} - if i := strings.LastIndexByte(s, '+'); i >= 0 { - build := parseConanVersion(s[i+1:]) - v.build, s = &build, s[:i] - } - if i := strings.LastIndexByte(s, '-'); i >= 0 { - pre := parseConanVersion(s[i+1:]) - v.pre, s = &pre, s[:i] - } - for _, item := range strings.Split(s, ".") { - v.main = append(v.main, conanItem{value: item, num: isDigits(item)}) + if c := compareConanMain(mainA, mainB); c != 0 { + return c } - for len(v.main) > 0 && v.main[len(v.main)-1].num && cmpNumStr(v.main[len(v.main)-1].value, "0") == 0 { - v.main = v.main[:len(v.main)-1] + if c := compareOptionalConan(preA, hasPreA, preB, hasPreB, true); c != 0 { + return c } - return v + return compareOptionalConan(buildA, hasBuildA, buildB, hasBuildB, false) } -func compareConanVersion(a, b conanVersion) int { - if c := compareConanItems(a.main, b.main); c != 0 { - return c +// splitConanVersion separates s into its main component list plus the optional +// prerelease and build parts, keeping every result as a view into s. The build +// part is taken first so that a prerelease containing a plus sign, such as +// 1.0-alpha+build, splits the same way the previous recursive parser did. +func splitConanVersion(s string) (main, pre string, hasPre bool, build string, hasBuild bool) { + if i := strings.LastIndexByte(s, '+'); i >= 0 { + build, hasBuild, s = s[i+1:], true, s[:i] } - if c := compareOptionalConan(a.pre, b.pre, true); c != 0 { - return c + if i := strings.LastIndexByte(s, '-'); i >= 0 { + pre, hasPre, s = s[i+1:], true, s[:i] } - return compareOptionalConan(a.build, b.build, false) + return s, pre, hasPre, build, hasBuild } -func compareConanItems(a, b []conanItem) int { - for i := 0; i < len(a) && i < len(b); i++ { +func compareConanMain(a, b string) int { + na, nb := conanMainLen(a), conanMainLen(b) + for i := 0; i < na && i < nb; i++ { + pa, restA, _ := nextDotPart(a) + pb, restB, _ := nextDotPart(b) var c int - if a[i].num && b[i].num { - c = cmpNumStr(a[i].value, b[i].value) + if isDigits(pa) && isDigits(pb) { + c = cmpNumStr(pa, pb) } else { - c = cmpString(a[i].value, b[i].value) + c = cmpString(pa, pb) } if c != 0 { return c } + a, b = restA, restB } - return cmpInt(len(a), len(b)) + return cmpInt(na, nb) } -func compareOptionalConan(a, b *conanVersion, prerelease bool) int { - if a == nil && b == nil { +// conanMainLen counts the dot separated components in s, less the trailing +// numeric zero components conan treats as absent so that 1.2.0 and 1.2 compare +// equal. +func conanMainLen(s string) int { + total, trailingZeros := 0, 0 + for { + part, rest, more := nextDotPart(s) + total++ + if isDigits(part) && cmpNumStr(part, "0") == 0 { + trailingZeros++ + } else { + trailingZeros = 0 + } + if !more { + return total - trailingZeros + } + s = rest + } +} + +// compareOptionalConan orders a present part against an absent one. A missing +// prerelease outranks a present one, while a missing build is outranked by a +// present one. Two present parts recurse through compareConan, which stays +// allocation free because every part is a view into the original string. +func compareOptionalConan(a string, hasA bool, b string, hasB bool, prerelease bool) int { + if !hasA && !hasB { return 0 } - if a == nil { + if !hasA { if prerelease { return 1 } return -1 } - if b == nil { + if !hasB { if prerelease { return -1 } return 1 } - return compareConanVersion(*a, *b) + return compareConan(a, b) } func compareGentoo(a, b string) int { @@ -192,11 +206,12 @@ func compareGentoo(a, b string) int { if va == vb { return cmpNumStr(ra, rb) } - pa, pb := strings.Split(va, "_"), strings.Split(vb, "_") - if c := compareGentooBase(pa[0], pb[0]); c != 0 { + baseA, suffixesA, hasSuffixA := splitGentooBase(va) + baseB, suffixesB, hasSuffixB := splitGentooBase(vb) + if c := compareGentooBase(baseA, baseB); c != 0 { return c } - if c := compareGentooSuffixes(pa[1:], pb[1:]); c != 0 { + if c := compareGentooSuffixes(suffixesA, hasSuffixA, suffixesB, hasSuffixB); c != 0 { return c } return cmpNumStr(ra, rb) @@ -210,66 +225,99 @@ func splitGentooRevision(s string) (version, revision string) { return version, revision } +// splitGentooBase separates the base component list from the underscore +// separated suffixes. hasSuffixes distinguishes a version with no underscore +// from one such as 1.2.3_ whose single suffix is empty. +func splitGentooBase(s string) (base, suffixes string, hasSuffixes bool) { + if i := strings.IndexByte(s, '_'); i >= 0 { + return s[:i], s[i+1:], true + } + return s, "", false +} + func compareGentooBase(a, b string) int { - pa, la := splitGentooBase(a) - pb, lb := splitGentooBase(b) - for i := 0; i < len(pa) && i < len(pb); i++ { - if pa[i] == pb[i] { - continue - } - var c int - if i == 0 || (!strings.HasPrefix(pa[i], "0") && !strings.HasPrefix(pb[i], "0")) { - c = cmpNumStr(pa[i], pb[i]) - } else { - c = cmpString(strings.TrimRight(pa[i], "0"), strings.TrimRight(pb[i], "0")) - } - if c != 0 { + a, letterA := splitGentooLetter(a) + b, letterB := splitGentooLetter(b) + + for i := 0; ; i++ { + pa, restA, moreA := nextDotPart(a) + pb, restB, moreB := nextDotPart(b) + if c := compareGentooComponent(i, pa, pb); c != 0 { return c } + if !moreA || !moreB { + if moreA != moreB { + return cmpInt(boolInt(moreA), boolInt(moreB)) + } + return cmpInt(letterA, letterB) + } + a, b = restA, restB } - if len(pa) != len(pb) { - return cmpInt(len(pa), len(pb)) +} + +// splitGentooLetter strips the single trailing letter a version such as 1.2.3a +// may carry. letter is -1 when there is none, which sorts it below any letter. +func splitGentooLetter(s string) (rest string, letter int) { + if s != "" && isASCIIAlpha(s[len(s)-1]) { + return s[:len(s)-1], int(s[len(s)-1]) } - return cmpInt(la, lb) + return s, -1 } -func splitGentooBase(s string) ([]string, int) { - parts := strings.Split(s, ".") - letter := -1 - last := parts[len(parts)-1] - if len(last) > 0 && isASCIIAlpha(last[len(last)-1]) { - letter = int(last[len(last)-1]) - parts[len(parts)-1] = last[:len(last)-1] - } - return parts, letter +// compareGentooComponent orders one pair of base components. Only the leading +// component is always numeric; a later component starting with a zero is +// compared as a fraction, with trailing zeros ignored. +func compareGentooComponent(index int, a, b string) int { + if a == b { + return 0 + } + if index == 0 || (!strings.HasPrefix(a, "0") && !strings.HasPrefix(b, "0")) { + return cmpNumStr(a, b) + } + return cmpString(strings.TrimRight(a, "0"), strings.TrimRight(b, "0")) } -func compareGentooSuffixes(a, b []string) int { - for i := 0; i < len(a) || i < len(b); i++ { - if i >= len(a) { - kind, number := parseGentooSuffix(b[i]) +func compareGentooSuffixes(a string, hasA bool, b string, hasB bool) int { + for { + if !hasA && !hasB { + return 0 + } + if !hasA { + suffix, _, _ := nextGentooSuffix(b) + kind, number := parseGentooSuffix(suffix) if rank := gentooSuffixRank(kind); rank != 0 { return cmpInt(0, rank) } return cmpNumStr("0", number) } - if i >= len(b) { - kind, number := parseGentooSuffix(a[i]) + if !hasB { + suffix, _, _ := nextGentooSuffix(a) + kind, number := parseGentooSuffix(suffix) if rank := gentooSuffixRank(kind); rank != 0 { return cmpInt(rank, 0) } return cmpNumStr(number, "0") } - ka, na := parseGentooSuffix(a[i]) - kb, nb := parseGentooSuffix(b[i]) + suffixA, restA, moreA := nextGentooSuffix(a) + suffixB, restB, moreB := nextGentooSuffix(b) + ka, na := parseGentooSuffix(suffixA) + kb, nb := parseGentooSuffix(suffixB) if c := cmpInt(gentooSuffixRank(ka), gentooSuffixRank(kb)); c != 0 { return c } if c := cmpNumStr(na, nb); c != 0 { return c } + a, b, hasA, hasB = restA, restB, moreA, moreB } - return 0 +} + +// nextGentooSuffix splits the leading underscore separated suffix off s. +func nextGentooSuffix(s string) (suffix, rest string, more bool) { + if i := strings.IndexByte(s, '_'); i >= 0 { + return s[:i], s[i+1:], true + } + return s, "", false } func parseGentooSuffix(s string) (kind, number string) { diff --git a/ecosystem_extra_parity_test.go b/ecosystem_extra_parity_test.go new file mode 100644 index 0000000..853a038 --- /dev/null +++ b/ecosystem_extra_parity_test.go @@ -0,0 +1,145 @@ +package vers + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// parityCorpus is a deliberately awkward set of inputs for the ALPM, Conan and +// Gentoo comparators. It covers the separators each scheme treats specially, +// the empty and separator-only strings that decide how many components a +// version has, leading and trailing zeros, and the letter and suffix forms that +// drive Gentoo ordering. +func parityCorpus() []string { + return []string{ + "", "0", "1", "00", "01", "0.0", "1.0", "1.0.0", "1.2", "1.2.3", "1.2.3.4", + ".", "..", "1.", ".1", "1..2", "1.0.", "0.0.0", + "1.2.3a", "1.2.3z", "1.2.3A", "a", "a.b", "1a.2b", "abc", "ABC", + "-", "1-", "-1", "1-2", "1.2-3", "1.2.3-1", "1.2.3-2", "1.2.3-10", + "+", "1+", "+1", "1+2", "1.0+build", "1.0-alpha+build", "1.0+build-alpha", + "1.0-alpha", "1.0-alpha.1", "1.0-alpha-beta", "1.0-beta", "1.0-rc1", "1.0-rc.1", + "_", "1_", "_1", "1.2.3_", "1.2.3_p", "1.2.3_p1", "1.2.3_pre", "1.2.3_pre1", + "1.2.3_alpha", "1.2.3_alpha1", "1.2.3_beta", "1.2.3_beta2", "1.2.3_rc", "1.2.3_rc1", + "1.2.3_alpha_p1", "1.2.3_rc1_p2", "1.2.3_p1_alpha", "1.2.3__p", + "1.2.3-r0", "1.2.3-r1", "1.2.3-r10", "1.2.3-r", "1.2.3-rx", "1.2.3_p1-r2", + "1.2.3a-r1", "1.2.3a_p1", "0.1.0_alpha", "2.34", + ":", "1:1.0", "2:1.0", "0:1.0", ":1.0", "1:", "1:2:3", + "1:1.2.3-1", "1:1.2.3-2", "2:1.2.3-1", + "1.0.0.0.0", "10.0", "9.0", "1.10", "1.9", + "20240101", "2024.01.01", "1.2.3.post1", "1.2.3rc1", + "~", "1~2", "1.2.3~rc1", "$", "1.2.3$", "1.2.3+-_", + } +} + +// fixtureVersions harvests every version string the local conformance fixtures +// use for the schemes this file covers, so the parity check runs against real +// upstream data as well as the synthetic corpus. +func fixtureVersions(t *testing.T) []string { + t.Helper() + + files := []string{ + "alpm_univers_version_cmp_test.json", + "conan_univers_version_cmp_test.json", + "gentoo_univers_version_cmp_test.json", + "apk_univers_version_cmp_test.json", + } + + seen := make(map[string]bool) + var versions []string + for _, name := range files { + path := filepath.Join("testdata", "local", "tests", name) + data, err := os.ReadFile(path) + if err != nil { + t.Logf("skipping %s: %v", name, err) + continue + } + // Only input.versions is decoded. expected_output is typed per test + // kind, a bool for containment cases and a list for ordering ones, and + // it never carries a version the input did not already list. + var doc struct { + Tests []struct { + Input struct { + Versions []string `json:"versions"` + } `json:"input"` + } `json:"tests"` + } + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("parsing %s: %v", path, err) + } + for _, test := range doc.Tests { + for _, v := range test.Input.Versions { + if !seen[v] { + seen[v] = true + versions = append(versions, v) + } + } + } + } + if len(versions) == 0 { + t.Fatal("no fixture versions harvested; the testdata submodule may be missing") + } + return versions +} + +// assertParity checks that the rewritten comparator returns exactly what the +// pre-optimization reference returns for every ordered pair, including a value +// against itself. +func assertParity(t *testing.T, name string, versions []string, got, want func(a, b string) int) { + t.Helper() + for _, a := range versions { + for _, b := range versions { + if g, w := got(a, b), want(a, b); g != w { + t.Fatalf("%s(%q, %q) = %d, reference implementation returns %d", name, a, b, g, w) + } + } + } +} + +func TestCompareALPMMatchesReference(t *testing.T) { + assertParity(t, "compareALPM", parityCorpus(), compareALPM, refCompareALPM) +} + +func TestCompareConanMatchesReference(t *testing.T) { + assertParity(t, "compareConan", parityCorpus(), compareConan, refCompareConan) +} + +func TestCompareGentooMatchesReference(t *testing.T) { + assertParity(t, "compareGentoo", parityCorpus(), compareGentoo, refCompareGentoo) +} + +func TestEcosystemComparatorsMatchReferenceOnFixtures(t *testing.T) { + versions := fixtureVersions(t) + assertParity(t, "compareALPM", versions, compareALPM, refCompareALPM) + assertParity(t, "compareConan", versions, compareConan, refCompareConan) + assertParity(t, "compareGentoo", versions, compareGentoo, refCompareGentoo) +} + +// TestEcosystemComparatorsDoNotAllocate is the point of the rewrite: these +// comparators must run entirely out of stack storage. +func TestEcosystemComparatorsDoNotAllocate(t *testing.T) { + tests := []struct { + name string + compare func(a, b string) int + a, b string + }{ + {name: "ALPM", compare: compareALPM, a: "1:1.2.3-1", b: "1:1.2.4-1"}, + {name: "ALPM equal prefix", compare: compareALPM, a: "1.2.3alpha1-1", b: "1.2.3beta1-1"}, + {name: "Conan", compare: compareConan, a: "1.2.3-rc1", b: "1.2.3"}, + {name: "Conan build", compare: compareConan, a: "1.2.0-alpha+build1", b: "1.2-alpha+build2"}, + {name: "Gentoo", compare: compareGentoo, a: "1.2.3_rc1", b: "1.2.3"}, + {name: "Gentoo revision", compare: compareGentoo, a: "1.2.3a_p1-r2", b: "1.2.3a_p2-r1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := testing.AllocsPerRun(100, func() { + tt.compare(tt.a, tt.b) + }) + if got != 0 { + t.Errorf("%s comparison allocated %v times per run, want 0", tt.name, got) + } + }) + } +} diff --git a/ecosystem_extra_reference_test.go b/ecosystem_extra_reference_test.go new file mode 100644 index 0000000..ecb010c --- /dev/null +++ b/ecosystem_extra_reference_test.go @@ -0,0 +1,305 @@ +package vers + +// This file is a mechanically renamed copy of the pre-optimization +// implementations of compareALPM, compareConan and compareGentoo. It exists so +// the differential tests in ecosystem_extra_parity_test.go can assert that the +// allocation-free rewrites order every input exactly the way the previous code +// did. Do not edit it to fix a behavior difference; fix the real +// implementation instead. + +import "strings" + +const ( + refSegmentDigit = iota + refSegmentAlpha + refSegmentOther +) + +type refTypedSegment struct { + value string + kind int +} + +func refCompareALPM(a, b string) int { + ea, va, ra, hasRA := refSplitALPMVersion(a) + eb, vb, rb, hasRB := refSplitALPMVersion(b) + if c := refCompareALPMPart(ea, eb); c != 0 { + return c + } + if c := refCompareALPMPart(va, vb); c != 0 { + return c + } + if hasRA && hasRB { + return refCompareALPMPart(ra, rb) + } + return 0 +} + +func refSplitALPMVersion(s string) (epoch, version, release string, hasRelease bool) { + epoch, version = "0", s + if i := strings.IndexByte(version, ':'); i >= 0 { + epoch, version = version[:i], version[i+1:] + } + if i := strings.LastIndexByte(version, '-'); i >= 0 { + version, release, hasRelease = version[:i], version[i+1:], true + } + return epoch, version, release, hasRelease +} + +func refSplitTypedSegments(s string) []refTypedSegment { + if s == "" { + return nil + } + segments := make([]refTypedSegment, 0) + start, kind := 0, refSegmentKind(s[0]) + for i := 1; i < len(s); i++ { + if next := refSegmentKind(s[i]); next != kind { + segments = append(segments, refTypedSegment{value: s[start:i], kind: kind}) + start, kind = i, next + } + } + return append(segments, refTypedSegment{value: s[start:], kind: kind}) +} + +func refSegmentKind(c byte) int { + if isASCIIDigit(c) { + return refSegmentDigit + } + if isASCIIAlpha(c) { + return refSegmentAlpha + } + return refSegmentOther +} + +func refCompareALPMPart(a, b string) int { + pa, pb := refSplitTypedSegments(a), refSplitTypedSegments(b) + for i := 0; i < len(pa) || i < len(pb); i++ { + if i >= len(pa) { + if pb[i].kind == refSegmentAlpha { + return 1 + } + return -1 + } + if i >= len(pb) { + if pa[i].kind == refSegmentAlpha { + return -1 + } + return 1 + } + a, b := pa[i], pb[i] + if a.kind != b.kind { + if a.kind == refSegmentDigit { + return 1 + } + if b.kind == refSegmentDigit { + return -1 + } + if a.kind == refSegmentOther { + return 1 + } + return -1 + } + var c int + switch a.kind { + case refSegmentDigit: + c = cmpNumStr(a.value, b.value) + case refSegmentAlpha: + c = cmpString(a.value, b.value) + default: + c = cmpInt(len(a.value), len(b.value)) + } + if c != 0 { + return c + } + } + return 0 +} + +type refConanVersion struct { + main []refConanItem + pre *refConanVersion + build *refConanVersion +} + +type refConanItem struct { + value string + num bool +} + +func refCompareConan(a, b string) int { + return refCompareConanVersion(refParseConanVersion(a), refParseConanVersion(b)) +} + +func refParseConanVersion(s string) refConanVersion { + v := refConanVersion{} + if i := strings.LastIndexByte(s, '+'); i >= 0 { + build := refParseConanVersion(s[i+1:]) + v.build, s = &build, s[:i] + } + if i := strings.LastIndexByte(s, '-'); i >= 0 { + pre := refParseConanVersion(s[i+1:]) + v.pre, s = &pre, s[:i] + } + for _, item := range strings.Split(s, ".") { + v.main = append(v.main, refConanItem{value: item, num: isDigits(item)}) + } + for len(v.main) > 0 && v.main[len(v.main)-1].num && cmpNumStr(v.main[len(v.main)-1].value, "0") == 0 { + v.main = v.main[:len(v.main)-1] + } + return v +} + +func refCompareConanVersion(a, b refConanVersion) int { + if c := refCompareConanItems(a.main, b.main); c != 0 { + return c + } + if c := refCompareOptionalConan(a.pre, b.pre, true); c != 0 { + return c + } + return refCompareOptionalConan(a.build, b.build, false) +} + +func refCompareConanItems(a, b []refConanItem) int { + for i := 0; i < len(a) && i < len(b); i++ { + var c int + if a[i].num && b[i].num { + c = cmpNumStr(a[i].value, b[i].value) + } else { + c = cmpString(a[i].value, b[i].value) + } + if c != 0 { + return c + } + } + return cmpInt(len(a), len(b)) +} + +func refCompareOptionalConan(a, b *refConanVersion, prerelease bool) int { + if a == nil && b == nil { + return 0 + } + if a == nil { + if prerelease { + return 1 + } + return -1 + } + if b == nil { + if prerelease { + return -1 + } + return 1 + } + return refCompareConanVersion(*a, *b) +} + +func refCompareGentoo(a, b string) int { + va, ra := refSplitGentooRevision(a) + vb, rb := refSplitGentooRevision(b) + if va == vb { + return cmpNumStr(ra, rb) + } + pa, pb := strings.Split(va, "_"), strings.Split(vb, "_") + if c := refCompareGentooBase(pa[0], pb[0]); c != 0 { + return c + } + if c := refCompareGentooSuffixes(pa[1:], pb[1:]); c != 0 { + return c + } + return cmpNumStr(ra, rb) +} + +func refSplitGentooRevision(s string) (version, revision string) { + version = s + if i := strings.LastIndex(s, "-r"); i >= 0 && isDigits(s[i+2:]) { + version, revision = s[:i], s[i+2:] + } + return version, revision +} + +func refCompareGentooBase(a, b string) int { + pa, la := refSplitGentooBase(a) + pb, lb := refSplitGentooBase(b) + for i := 0; i < len(pa) && i < len(pb); i++ { + if pa[i] == pb[i] { + continue + } + var c int + if i == 0 || (!strings.HasPrefix(pa[i], "0") && !strings.HasPrefix(pb[i], "0")) { + c = cmpNumStr(pa[i], pb[i]) + } else { + c = cmpString(strings.TrimRight(pa[i], "0"), strings.TrimRight(pb[i], "0")) + } + if c != 0 { + return c + } + } + if len(pa) != len(pb) { + return cmpInt(len(pa), len(pb)) + } + return cmpInt(la, lb) +} + +func refSplitGentooBase(s string) ([]string, int) { + parts := strings.Split(s, ".") + letter := -1 + last := parts[len(parts)-1] + if len(last) > 0 && isASCIIAlpha(last[len(last)-1]) { + letter = int(last[len(last)-1]) + parts[len(parts)-1] = last[:len(last)-1] + } + return parts, letter +} + +func refCompareGentooSuffixes(a, b []string) int { + for i := 0; i < len(a) || i < len(b); i++ { + if i >= len(a) { + kind, number := refParseGentooSuffix(b[i]) + if rank := refGentooSuffixRank(kind); rank != 0 { + return cmpInt(0, rank) + } + return cmpNumStr("0", number) + } + if i >= len(b) { + kind, number := refParseGentooSuffix(a[i]) + if rank := refGentooSuffixRank(kind); rank != 0 { + return cmpInt(rank, 0) + } + return cmpNumStr(number, "0") + } + ka, na := refParseGentooSuffix(a[i]) + kb, nb := refParseGentooSuffix(b[i]) + if c := cmpInt(refGentooSuffixRank(ka), refGentooSuffixRank(kb)); c != 0 { + return c + } + if c := cmpNumStr(na, nb); c != 0 { + return c + } + } + return 0 +} + +func refParseGentooSuffix(s string) (kind, number string) { + i := len(s) + for i > 0 && isASCIIDigit(s[i-1]) { + i-- + } + return s[:i], s[i:] +} + +func refGentooSuffixRank(s string) int { + switch s { + case qualifierAlpha: + return -4 + case qualifierBeta: + return -3 + case qualifierPre: + return -2 + case "rc": + return -1 + case "p": + return 1 + default: + return 0 + } +} diff --git a/schemes.go b/schemes.go index a7960ba..05f8ffc 100644 --- a/schemes.go +++ b/schemes.go @@ -252,8 +252,8 @@ func compareSemverPrereleaseStrings(a, b string) int { } for { - aPart, aRest, aMore := nextSemverIdentifier(a) - bPart, bRest, bMore := nextSemverIdentifier(b) + aPart, aRest, aMore := nextDotPart(a) + bPart, bRest, bMore := nextDotPart(b) aNum, bNum := isDigits(aPart), isDigits(bPart) if aNum != bNum { if aNum { @@ -277,7 +277,10 @@ func compareSemverPrereleaseStrings(a, b string) int { } } -func nextSemverIdentifier(s string) (part, rest string, more bool) { +// nextDotPart splits the leading dot separated component off s. more reports +// whether another component follows, which lets callers walk two versions in +// lockstep without materializing either component list. +func nextDotPart(s string) (part, rest string, more bool) { i := strings.IndexByte(s, '.') if i < 0 { return s, "", false From ae601c270452a5ef6170fe7c1b0f6f10c3d9d082 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Wed, 19 Aug 2026 22:55:19 +0530 Subject: [PATCH 2/2] Fold ecosystem cases into the existing comparison benchmark table --- bench_test.go | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/bench_test.go b/bench_test.go index e1528d6..fb91957 100644 --- a/bench_test.go +++ b/bench_test.go @@ -115,29 +115,15 @@ func BenchmarkCompareWithScheme(b *testing.B) { {name: "NuGet", a: "1.2.3-alpha.1", other: "1.2.3", scheme: "nuget"}, {name: "Debian", a: "1:1.2.3-1", other: "1:1.2.3-2", scheme: "deb"}, {name: "RPM", a: "1:1.2.3-1", other: "1:1.2.3-2", scheme: "rpm"}, - } - for _, tt := range tests { - b.Run(tt.name, func(b *testing.B) { - for b.Loop() { - CompareWithScheme(tt.a, tt.other, tt.scheme) - } - }) - } -} - -// BenchmarkCompareEcosystem covers the scheme specific comparators that do not -// go through the shared SemVer path, so allocation regressions in them show up -// directly. -func BenchmarkCompareEcosystem(b *testing.B) { - tests := []struct { - name, a, other, scheme string - }{ + // The remaining schemes have their own comparators rather than + // sharing the SemVer path, so allocation regressions in them only + // show up if they are measured here. {name: "ALPM", a: "1.2.3-1", other: "1.2.4-1", scheme: "alpm"}, - {name: "ALPM_Epoch", a: "1:1.2.3-1", other: "2:1.2.3-1", scheme: "alpm"}, + {name: "ALPMEpoch", a: "1:1.2.3-1", other: "2:1.2.3-1", scheme: "alpm"}, {name: "Conan", a: "1.2.3-rc1", other: "1.2.3", scheme: "conan"}, - {name: "Conan_Build", a: "1.2.0-alpha+build1", other: "1.2-alpha+build2", scheme: "conan"}, + {name: "ConanBuild", a: "1.2.0-alpha+build1", other: "1.2-alpha+build2", scheme: "conan"}, {name: "Gentoo", a: "1.2.3_rc1", other: "1.2.3", scheme: "gentoo"}, - {name: "Gentoo_Revision", a: "1.2.3a_p1-r2", other: "1.2.3a_p2-r1", scheme: "gentoo"}, + {name: "GentooRevision", a: "1.2.3a_p1-r2", other: "1.2.3a_p2-r1", scheme: "gentoo"}, {name: "APK", a: "1.2.3_rc1", other: "1.2.3", scheme: "apk"}, } for _, tt := range tests {