mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-06 10:26:49 +00:00
ref(lexver): support arbitrary version depth, use time.Time for dates
Versions aren't always semver — chromedriver uses 4 parts, gpg uses 4 parts, atomicparsley uses dates. Replaced fixed Major/Minor/Patch/Build fields with a Nums slice. Date is now time.Time for minute-level precision. Also adds ODDITIES.md cataloging non-standard version formats across Webi packages for future reference.
This commit is contained in:
55
ODDITIES.md
Normal file
55
ODDITIES.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Version & Release Oddities
|
||||
|
||||
Known non-standard version formats and release quirks across Webi packages.
|
||||
This matters for version parsing, sorting, and classification.
|
||||
|
||||
## 4-Part Versions
|
||||
|
||||
| Package | Example | Notes |
|
||||
|---------|---------|-------|
|
||||
| chromedriver | `121.0.6120.0` | From Google Chrome's versioning. No transform applied. |
|
||||
| gpg | `2.2.19.0` | 4th segment treated as build metadata. `releases.js` converts to `2.2.19+0`. |
|
||||
|
||||
## Date-Based Versions
|
||||
|
||||
| Package | Notes |
|
||||
|---------|-------|
|
||||
| atomicparsley | Uses date-based version strings. |
|
||||
|
||||
## Non-Numeric Tag Prefixes
|
||||
|
||||
| Package | Raw Tag | Cleaned | Transform |
|
||||
|---------|---------|---------|-----------|
|
||||
| lf | `r21` | `0.21.0` | `r` prefix → prepend `0.` |
|
||||
| bun | `bun-v1.0.0` | `1.0.0` | Strip `bun-` prefix |
|
||||
| jq | `jq-1.7` | `1.7` | Strip `jq-` prefix |
|
||||
| watchexec | `cli-v1.2.3` | `1.2.3` | Strip `cli-` prefix |
|
||||
| ffmpeg | `b6.0` | `6.0` | Strip `b` prefix |
|
||||
|
||||
## Underscore-Delimited Tags
|
||||
|
||||
| Package | Raw Tag | Cleaned | Transform |
|
||||
|---------|---------|---------|-----------|
|
||||
| postgres | `REL_17_0` | `17.0` | Strip `REL_`, replace `_` with `.` |
|
||||
| psql | `REL_17_0` | `17.0` | Same as postgres |
|
||||
| pg | `REL_17_0` | `17.0` | Same as postgres |
|
||||
|
||||
## Platform Suffix in Version
|
||||
|
||||
| Package | Raw Tag | Cleaned | Transform |
|
||||
|---------|---------|---------|-----------|
|
||||
| git (Windows) | `2.41.0.windows.1` | `2.41.0` | Strip `.windows.N` suffix |
|
||||
|
||||
## Complex Pre-Release Formats
|
||||
|
||||
| Package | Example | Notes |
|
||||
|---------|---------|-------|
|
||||
| flutter | `2.3.0-16.0.pre` | Pre-release with extra dots and numeric segments |
|
||||
| iterm2 | `iTerm2_3_5_0beta17` | Underscores as separators, beta attached without dash → `3.5.0-beta17` |
|
||||
|
||||
## Bundled Dependency Versions (Not Package Versions)
|
||||
|
||||
| Package | Context | Example |
|
||||
|---------|---------|---------|
|
||||
| node | v8 engine version in metadata | `11.3.244.8` (4-part) |
|
||||
| node | zlib version in metadata | `1.2.13.1-motley` (4-part + suffix) |
|
||||
@@ -1,31 +1,36 @@
|
||||
// Package lexver makes version strings comparable and sortable.
|
||||
//
|
||||
// Version numbers like "1.20.3" look numeric but aren't — as raw strings
|
||||
// "1.2" > "1.20". Webi needs to find "the latest 1.20.x" or "the newest
|
||||
// stable release" from a list. Lexver parses version strings into a struct
|
||||
// and provides a comparison function for use with [slices.SortFunc].
|
||||
// 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 {
|
||||
Major int
|
||||
Minor int
|
||||
Patch int
|
||||
Channel string // "" for stable, or "alpha", "beta", "dev", "pre", "preview", "rc"
|
||||
ChannelNum int // e.g. 2 in "rc2"
|
||||
Date string // release date "2024-01-15", if known (takes precedence over build)
|
||||
Raw string // original string as provided
|
||||
// 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
|
||||
Raw string // original string as provided
|
||||
}
|
||||
|
||||
// Parse breaks a version string into its components.
|
||||
@@ -35,17 +40,8 @@ func Parse(s string) Version {
|
||||
s = strings.TrimLeft(s, "vV")
|
||||
|
||||
numStr, prerelease := splitAtPrerelease(s)
|
||||
v.Nums = splitNums(numStr)
|
||||
|
||||
nums := splitNums(numStr)
|
||||
if len(nums) > 0 {
|
||||
v.Major = nums[0]
|
||||
}
|
||||
if len(nums) > 1 {
|
||||
v.Minor = nums[1]
|
||||
}
|
||||
if len(nums) > 2 {
|
||||
v.Patch = nums[2]
|
||||
}
|
||||
if prerelease != "" {
|
||||
v.Channel, v.ChannelNum = splitChannel(prerelease)
|
||||
}
|
||||
@@ -53,6 +49,22 @@ func Parse(s string) Version {
|
||||
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 == ""
|
||||
@@ -61,25 +73,23 @@ func (v Version) IsStable() bool {
|
||||
// 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 {
|
||||
if c := cmp.Compare(a.Major, b.Major); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Minor, b.Minor); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Patch, b.Patch); c != 0 {
|
||||
return c
|
||||
}
|
||||
|
||||
// If both have dates, use them to break ties within the same
|
||||
// major.minor.patch. Dates are ISO strings so they compare correctly.
|
||||
if a.Date != "" && b.Date != "" {
|
||||
if c := cmp.Compare(a.Date, b.Date); c != 0 {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Both stable → equal (in version terms).
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Both stable → equal.
|
||||
if a.Channel == "" && b.Channel == "" {
|
||||
return 0
|
||||
}
|
||||
@@ -98,17 +108,13 @@ func Compare(a, b Version) int {
|
||||
}
|
||||
|
||||
// HasPrefix reports whether v matches a partial version prefix.
|
||||
// A prefix matches if its non-zero fields equal the corresponding fields in v.
|
||||
// For example, prefix {Major:1, Minor:20} matches any 1.20.x version.
|
||||
// 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 {
|
||||
if prefix.Major != v.Major {
|
||||
return false
|
||||
}
|
||||
if prefix.Minor != 0 && prefix.Minor != v.Minor {
|
||||
return false
|
||||
}
|
||||
if prefix.Patch != 0 && prefix.Patch != v.Patch {
|
||||
return false
|
||||
for i, pn := range prefix.Nums {
|
||||
if i >= len(v.Nums) || v.Nums[i] != pn {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -133,6 +139,7 @@ func splitAtPrerelease(s string) (string, string) {
|
||||
}
|
||||
|
||||
// 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, ".") {
|
||||
|
||||
@@ -3,6 +3,7 @@ package lexver_test
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/webinstall/webi-installers/internal/lexver"
|
||||
)
|
||||
@@ -10,31 +11,39 @@ import (
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
nums []int
|
||||
channel string
|
||||
chanNum int
|
||||
}{
|
||||
{"1.0.0", 1, 0, 0, "", 0},
|
||||
{"v1.2.3", 1, 2, 3, "", 0},
|
||||
{"1.20.156", 1, 20, 156, "", 0},
|
||||
{"1.20", 1, 20, 0, "", 0},
|
||||
{"1", 1, 0, 0, "", 0},
|
||||
{"1.0.0-beta1", 1, 0, 0, "beta", 1},
|
||||
{"1.0.0-rc2", 1, 0, 0, "rc", 2},
|
||||
{"2.0.0-alpha3", 2, 0, 0, "alpha", 3},
|
||||
{"1.0.0-dev", 1, 0, 0, "dev", 0},
|
||||
{"1.2beta3", 1, 2, 0, "beta", 3},
|
||||
{"1.0rc1", 1, 0, 0, "rc", 1},
|
||||
// Standard semver
|
||||
{"1.0.0", []int{1, 0, 0}, "", 0},
|
||||
{"v1.2.3", []int{1, 2, 3}, "", 0},
|
||||
{"1.20.156", []int{1, 20, 156}, "", 0},
|
||||
|
||||
// Partial
|
||||
{"1.20", []int{1, 20}, "", 0},
|
||||
{"1", []int{1}, "", 0},
|
||||
|
||||
// 4-part (chromedriver, gpg)
|
||||
{"121.0.6120.0", []int{121, 0, 6120, 0}, "", 0},
|
||||
{"2.2.19.0", []int{2, 2, 19, 0}, "", 0},
|
||||
|
||||
// Pre-release
|
||||
{"1.0.0-beta1", []int{1, 0, 0}, "beta", 1},
|
||||
{"1.0.0-rc2", []int{1, 0, 0}, "rc", 2},
|
||||
{"2.0.0-alpha3", []int{2, 0, 0}, "alpha", 3},
|
||||
{"1.0.0-dev", []int{1, 0, 0}, "dev", 0},
|
||||
|
||||
// No separator before channel
|
||||
{"1.2beta3", []int{1, 2}, "beta", 3},
|
||||
{"1.0rc1", []int{1, 0}, "rc", 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
v := lexver.Parse(tt.input)
|
||||
if v.Major != tt.major || v.Minor != tt.minor || v.Patch != tt.patch {
|
||||
t.Errorf("Parse(%q) = %d.%d.%d, want %d.%d.%d",
|
||||
tt.input, v.Major, v.Minor, v.Patch, tt.major, tt.minor, tt.patch)
|
||||
if !slices.Equal(v.Nums, tt.nums) {
|
||||
t.Errorf("Parse(%q).Nums = %v, want %v", tt.input, v.Nums, tt.nums)
|
||||
}
|
||||
if v.Channel != tt.channel || v.ChannelNum != tt.chanNum {
|
||||
t.Errorf("Parse(%q) channel = %q/%d, want %q/%d",
|
||||
@@ -44,6 +53,18 @@ func TestParse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessors(t *testing.T) {
|
||||
v := lexver.Parse("121.0.6120.0")
|
||||
if v.Major() != 121 || v.Minor() != 0 || v.Patch() != 6120 {
|
||||
t.Errorf("got %d.%d.%d, want 121.0.6120", v.Major(), v.Minor(), v.Patch())
|
||||
}
|
||||
|
||||
short := lexver.Parse("1")
|
||||
if short.Minor() != 0 || short.Patch() != 0 {
|
||||
t.Error("missing segments should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortOrder(t *testing.T) {
|
||||
// Must be in ascending order.
|
||||
ordered := []string{
|
||||
@@ -71,6 +92,39 @@ func TestSortOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortOrder4Part(t *testing.T) {
|
||||
ordered := []string{
|
||||
"121.0.6120.0",
|
||||
"121.0.6120.1",
|
||||
"121.0.6121.0",
|
||||
"122.0.6100.0",
|
||||
}
|
||||
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
a := lexver.Parse(ordered[i-1])
|
||||
b := lexver.Parse(ordered[i])
|
||||
if lexver.Compare(a, b) >= 0 {
|
||||
t.Errorf("expected %q < %q", ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMismatchedDepth(t *testing.T) {
|
||||
// "1.0" and "1.0.0" should be equal (trailing zeros).
|
||||
a := lexver.Parse("1.0")
|
||||
b := lexver.Parse("1.0.0")
|
||||
if lexver.Compare(a, b) != 0 {
|
||||
t.Error("1.0 and 1.0.0 should be equal")
|
||||
}
|
||||
|
||||
// "1.0.0.1" should be greater than "1.0.0".
|
||||
c := lexver.Parse("1.0.0.1")
|
||||
d := lexver.Parse("1.0.0")
|
||||
if lexver.Compare(c, d) <= 0 {
|
||||
t.Error("1.0.0.1 should be greater than 1.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortFunc(t *testing.T) {
|
||||
versions := []string{"1.0.0", "2.0.0-rc1", "1.20.3", "1.20.2", "1.19.5", "2.0.0"}
|
||||
parsed := make([]lexver.Version, len(versions))
|
||||
@@ -97,8 +151,8 @@ func TestIsStable(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{"1.0.0", true},
|
||||
{"121.0.6120.0", true},
|
||||
{"1.0.0-beta1", false},
|
||||
{"1.0.0-rc2", false},
|
||||
{"v2.0.0-dev", false},
|
||||
}
|
||||
|
||||
@@ -114,10 +168,10 @@ func TestIsStable(t *testing.T) {
|
||||
|
||||
func TestDateTiebreaker(t *testing.T) {
|
||||
a := lexver.Parse("1.0.0")
|
||||
a.Date = "2024-01-15"
|
||||
a.Date = time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
b := lexver.Parse("1.0.0")
|
||||
b.Date = "2024-06-01"
|
||||
b.Date = time.Date(2024, 6, 1, 14, 30, 0, 0, time.UTC)
|
||||
|
||||
if lexver.Compare(a, b) >= 0 {
|
||||
t.Error("earlier date should sort before later date at same version")
|
||||
@@ -132,26 +186,47 @@ func TestDateTiebreaker(t *testing.T) {
|
||||
|
||||
// Date only matters when both have it.
|
||||
e := lexver.Parse("1.0.0")
|
||||
e.Date = "2024-01-15"
|
||||
e.Date = time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
f := lexver.Parse("1.0.0")
|
||||
if lexver.Compare(e, f) != 0 {
|
||||
t.Error("date should be ignored when only one side has it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateMinutePrecision(t *testing.T) {
|
||||
a := lexver.Parse("1.0.0")
|
||||
a.Date = time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
b := lexver.Parse("1.0.0")
|
||||
b.Date = time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
if lexver.Compare(a, b) >= 0 {
|
||||
t.Error("same date, later time should sort after")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPrefix(t *testing.T) {
|
||||
v := lexver.Parse("1.20.3")
|
||||
|
||||
if !v.HasPrefix(lexver.Version{Major: 1, Minor: 20}) {
|
||||
if !v.HasPrefix(lexver.Parse("1.20")) {
|
||||
t.Error("1.20.3 should match prefix 1.20")
|
||||
}
|
||||
if !v.HasPrefix(lexver.Version{Major: 1}) {
|
||||
if !v.HasPrefix(lexver.Parse("1")) {
|
||||
t.Error("1.20.3 should match prefix 1")
|
||||
}
|
||||
if v.HasPrefix(lexver.Version{Major: 1, Minor: 19}) {
|
||||
if v.HasPrefix(lexver.Parse("1.19")) {
|
||||
t.Error("1.20.3 should not match prefix 1.19")
|
||||
}
|
||||
if v.HasPrefix(lexver.Version{Major: 2}) {
|
||||
if v.HasPrefix(lexver.Parse("2")) {
|
||||
t.Error("1.20.3 should not match prefix 2")
|
||||
}
|
||||
|
||||
// 4-part prefix matching
|
||||
v4 := lexver.Parse("121.0.6120.0")
|
||||
if !v4.HasPrefix(lexver.Parse("121.0.6120")) {
|
||||
t.Error("121.0.6120.0 should match prefix 121.0.6120")
|
||||
}
|
||||
if !v4.HasPrefix(lexver.Parse("121.0")) {
|
||||
t.Error("121.0.6120.0 should match prefix 121.0")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user