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:
AJ ONeal
2026-03-08 21:58:33 -06:00
parent 66f9f5f5fe
commit c1a40cebf3
3 changed files with 208 additions and 71 deletions

55
ODDITIES.md Normal file
View 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) |

View File

@@ -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, ".") {

View File

@@ -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")
}
}