Files
vim-ale/internal/lexver/lexver.go
AJ ONeal 1374bca46b feat(lexver): add Original field and ExtraSort tiebreaker
Original preserves the upstream tag as the releaser published it (e.g.
"REL_17_0"), while Raw holds Webi's normalized form ("17.0").

ExtraSort is an opaque string for package-specific ordering where Nums
alone can't capture sort order (e.g. flutter "2.3.0-16.0.pre"). Set by
release-fetcher code using zero-padded strings or whatever works for
that package.
2026-03-08 22:58:58 -06:00

190 lines
5.2 KiB
Go

// Package lexver makes version strings comparable and sortable.
//
// Not all version strings are semver. Webi handles 4-part versions
// (chromedriver 121.0.6120.0), date-based versions (atomicparsley),
// and pre-releases with extra dots (flutter 2.3.0-16.0.pre). Lexver
// parses these into a struct with an arbitrary-depth numeric segment
// list and provides a comparison function for use with [slices.SortFunc].
//
// Pre-releases sort before their corresponding stable release:
//
// 1.0.0-alpha1 < 1.0.0-beta1 < 1.0.0-rc1 < 1.0.0
//
// When release dates are known, they break ties between versions with
// identical numeric segments.
package lexver
import (
"cmp"
"strconv"
"strings"
"time"
"unicode"
)
// Version is a parsed version with comparable fields.
type Version struct {
// Nums holds the dotted numeric segments in order.
// "1.20.3" → [1, 20, 3], "121.0.6120.0" → [121, 0, 6120, 0].
Nums []int
Channel string // "" for stable, or "alpha", "beta", "dev", "pre", "preview", "rc"
ChannelNum int // e.g. 2 in "rc2"
Date time.Time // release date/time, if known; breaks ties between same-numbered versions
Original string // version string exactly as the releaser published it (e.g. "REL_17_0", "r21")
Raw string // version string after Webi's normalization (e.g. "17.0", "0.21.0")
// ExtraSort is an optional opaque string for package-specific ordering.
// Set by release-fetcher code for packages where Nums alone can't capture
// the sort order (e.g. flutter's "2.3.0-16.0.pre"). Compared as a plain
// string, only consulted when Nums and Channel are equal.
ExtraSort string
}
// Parse breaks a version string into its components.
// Both Original and Raw are set to s; callers that normalize versions
// (e.g. "REL_17_0" → "17.0") should set Original to the upstream tag
// and pass the normalized string to Parse.
func Parse(s string) Version {
v := Version{Original: s, Raw: s}
s = strings.TrimLeft(s, "vV")
numStr, prerelease := splitAtPrerelease(s)
v.Nums = splitNums(numStr)
if prerelease != "" {
v.Channel, v.ChannelNum = splitChannel(prerelease)
}
return v
}
// Major returns the first numeric segment, or 0 if none.
func (v Version) Major() int { return v.num(0) }
// Minor returns the second numeric segment, or 0 if none.
func (v Version) Minor() int { return v.num(1) }
// Patch returns the third numeric segment, or 0 if none.
func (v Version) Patch() int { return v.num(2) }
func (v Version) num(i int) int {
if i < len(v.Nums) {
return v.Nums[i]
}
return 0
}
// IsStable reports whether this is a stable (non-pre-release) version.
func (v Version) IsStable() bool {
return v.Channel == ""
}
// Compare returns -1, 0, or 1 for ordering two versions.
// Stable releases sort after pre-releases of the same numeric version.
func Compare(a, b Version) int {
// Compare numeric segments pairwise, treating missing segments as 0.
n := max(len(a.Nums), len(b.Nums))
for i := range n {
an, bn := a.num(i), b.num(i)
if c := cmp.Compare(an, bn); c != 0 {
return c
}
}
// Break ties with release date when both are known.
if !a.Date.IsZero() && !b.Date.IsZero() {
if c := a.Date.Compare(b.Date); c != 0 {
return c
}
}
// ExtraSort: package-specific tiebreaker set by release-fetcher code.
if a.ExtraSort != "" && b.ExtraSort != "" {
if c := cmp.Compare(a.ExtraSort, b.ExtraSort); c != 0 {
return c
}
}
// Both stable → equal.
if a.Channel == "" && b.Channel == "" {
return 0
}
// Stable beats any pre-release.
if a.Channel == "" {
return 1
}
if b.Channel == "" {
return -1
}
// Both pre-release: alphabetical channel, then number.
if c := cmp.Compare(a.Channel, b.Channel); c != 0 {
return c
}
return cmp.Compare(a.ChannelNum, b.ChannelNum)
}
// HasPrefix reports whether v matches a partial version prefix.
// A prefix with Nums [1, 20] matches any version starting with 1.20
// (e.g. 1.20.0, 1.20.3, 1.20.3.1).
func (v Version) HasPrefix(prefix Version) bool {
for i, pn := range prefix.Nums {
if i >= len(v.Nums) || v.Nums[i] != pn {
return false
}
}
return true
}
// splitAtPrerelease splits "1.20.3-beta1" into ("1.20.3", "beta1").
// Also handles "1.2beta3" (no separator).
func splitAtPrerelease(s string) (string, string) {
for _, sep := range []byte{'-', '+'} {
if idx := strings.IndexByte(s, sep); idx >= 0 {
return s[:idx], s[idx+1:]
}
}
// "1.2beta3": letter following a digit
for i := 1; i < len(s); i++ {
if unicode.IsLetter(rune(s[i])) && unicode.IsDigit(rune(s[i-1])) {
return s[:i], s[i:]
}
}
return s, ""
}
// splitNums parses "1.20.3" into [1, 20, 3].
// Handles any number of dot-separated segments.
func splitNums(s string) []int {
var nums []int
for _, seg := range strings.Split(s, ".") {
n, err := strconv.Atoi(seg)
if err != nil {
break
}
nums = append(nums, n)
}
return nums
}
// splitChannel separates "beta1" into ("beta", 1) or "rc" into ("rc", 0).
func splitChannel(s string) (string, int) {
s = strings.ToLower(s)
s = strings.NewReplacer("-", "", ".", "", "_", "").Replace(s)
i := len(s)
for i > 0 && unicode.IsDigit(rune(s[i-1])) {
i--
}
name := s[:i]
num := 0
if i < len(s) {
num, _ = strconv.Atoi(s[i:])
}
return name, num
}