From c1a40cebf3d1dbd0d2fe655d2581400e5c35cb8f Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 8 Mar 2026 21:58:33 -0600 Subject: [PATCH] ref(lexver): support arbitrary version depth, use time.Time for dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ODDITIES.md | 55 +++++++++++++++ internal/lexver/lexver.go | 99 ++++++++++++++------------ internal/lexver/lexver_test.go | 125 ++++++++++++++++++++++++++------- 3 files changed, 208 insertions(+), 71 deletions(-) create mode 100644 ODDITIES.md diff --git a/ODDITIES.md b/ODDITIES.md new file mode 100644 index 0000000..ec6e695 --- /dev/null +++ b/ODDITIES.md @@ -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) | diff --git a/internal/lexver/lexver.go b/internal/lexver/lexver.go index 7282bd2..d33acd0 100644 --- a/internal/lexver/lexver.go +++ b/internal/lexver/lexver.go @@ -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, ".") { diff --git a/internal/lexver/lexver_test.go b/internal/lexver/lexver_test.go index f1305a4..71f6352 100644 --- a/internal/lexver/lexver_test.go +++ b/internal/lexver/lexver_test.go @@ -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") + } }