Files
vim-ale/internal/resolver/resolver.go
AJ ONeal d76e93c380 fix(resolver): prefer no-dep builds in libc waterfall
Static (none) first on all platforms — no runtime dependencies.
Linux: none, gnu, musl. Windows: none, msvc (vcredist not bundled).
2026-03-11 12:34:08 -06:00

417 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package resolver selects the best release asset for a given platform
// and version constraint.
//
// The resolver takes a package's full asset list and a request describing
// what the client needs (OS, arch, libc, version prefix, channel, format
// preferences). It returns the single best matching asset or an error.
//
// Resolution order:
// 1. Filter assets by channel (inclusive: @stable includes stable+lts)
// 2. Sort versions descending, filter by version prefix if given
// 3. For each candidate version, try compatible platform triplets
// (OS × CompatArches fallback × libc) in preference order
// 4. Among platform matches, pick the best format
// 5. Among format matches, prefer assets without build variants
package resolver
import (
"errors"
"slices"
"strings"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/lexver"
"github.com/webinstall/webi-installers/internal/storage"
)
// ErrNoMatch is returned when no asset matches the request.
var ErrNoMatch = errors.New("resolver: no matching asset")
// Request describes what the client is looking for.
type Request struct {
// OS is the target operating system (e.g. "linux", "darwin", "windows").
OS string
// Arch is the target architecture (e.g. "aarch64", "x86_64").
Arch string
// Libc is the preferred C library (e.g. "gnu", "musl", "msvc").
// Empty means no preference — the resolver tries all libc values.
Libc string
// Version is a version prefix constraint (e.g. "1.20", "1", "").
// Empty means latest. Exact versions like "1.20.3" also work.
Version string
// Channel selects the release stability level. Values:
// ""/"stable" — stable and LTS only (default)
// "lts" — LTS releases only
// "rc" — rc + stable + LTS
// "beta" — beta + rc + stable + LTS
// "alpha" — everything (alpha + beta + rc + stable + LTS)
// "pre" — alias for beta (package-specific meaning)
Channel string
// LTS when true selects only LTS-flagged releases.
LTS bool
// Formats lists acceptable archive formats in preference order.
// If empty, a default preference order is used.
Formats []string
// Variant selects a specific build variant (e.g. "rocm", "jetpack6").
// If empty, assets with variants are deprioritized.
Variant string
}
// Result holds the resolved asset and metadata about the match.
type Result struct {
// Asset is the selected download.
Asset storage.Asset
// Version is the matched version string.
Version string
// Triplet is the matched platform triplet (os-arch-libc).
Triplet string
}
// Resolve finds the best matching asset for the given request.
func Resolve(assets []storage.Asset, req Request) (Result, error) {
if len(assets) == 0 {
return Result{}, ErrNoMatch
}
// Parse the version prefix for filtering.
var versionPrefix lexver.Version
hasPrefix := req.Version != ""
if hasPrefix {
versionPrefix = lexver.Parse(req.Version)
}
// Build the channel filter.
channelOK := channelFilter(req.Channel, req.LTS)
// Parse and sort all unique versions descending.
type versionEntry struct {
parsed lexver.Version
raw string
}
seen := make(map[string]bool)
var versions []versionEntry
for _, a := range assets {
if seen[a.Version] {
continue
}
seen[a.Version] = true
v := lexver.Parse(a.Version)
v.Raw = a.Version
versions = append(versions, versionEntry{parsed: v, raw: a.Version})
}
slices.SortFunc(versions, func(a, b versionEntry) int {
return lexver.Compare(b.parsed, a.parsed) // descending
})
// Build platform fallback list: ordered (os, arch, libc) combinations.
triplets := enumerateTriplets(req.OS, req.Arch, req.Libc)
// Build format preference list.
formats := req.Formats
if len(formats) == 0 {
formats = defaultFormats(req.OS)
}
// Index assets by version+triplet for fast lookup.
// Assets with empty OS/Arch (like git repos) use "" keys.
type tripletKey struct {
version string
os string
arch string
libc string
}
index := make(map[tripletKey][]storage.Asset)
for _, a := range assets {
key := tripletKey{
version: a.Version,
os: a.OS,
arch: a.Arch,
libc: a.Libc,
}
index[key] = append(index[key], a)
}
// Walk versions in descending order.
for _, ve := range versions {
// Check version prefix.
if hasPrefix && !ve.parsed.HasPrefix(versionPrefix) {
continue
}
// Check channel.
if !channelOK(ve.parsed.Channel, ve.raw) {
continue
}
// Try each compatible triplet.
for _, tri := range triplets {
key := tripletKey{
version: ve.raw,
os: tri.os,
arch: tri.arch,
libc: tri.libc,
}
candidates := index[key]
if len(candidates) == 0 {
continue
}
// Pick the best asset from candidates.
best, ok := pickBest(candidates, formats, req.Variant, req.LTS)
if !ok {
continue
}
triplet := tri.os + "-" + tri.arch + "-" + tri.libc
return Result{
Asset: best,
Version: ve.raw,
Triplet: triplet,
}, nil
}
}
return Result{}, ErrNoMatch
}
// channelFilter returns a function that checks whether a given channel
// is acceptable for the requested channel level.
func channelFilter(requested string, ltsOnly bool) func(channel string, version string) bool {
if ltsOnly {
return func(_ string, _ string) bool {
// LTS filtering happens at the asset level, not version level.
// We let all versions through and filter by LTS flag later.
// Actually, LTS is per-asset, so we handle it in pickBest.
return true
}
}
requested = strings.ToLower(requested)
if requested == "" {
requested = "stable"
}
if requested == "pre" {
requested = "beta"
}
if requested == "latest" {
requested = "stable"
}
// channelRank maps channel names to a numeric rank.
// Higher rank = less stable. A request for rank N accepts
// everything at rank N or below.
rank := func(ch string) int {
ch = strings.ToLower(ch)
switch ch {
case "", "stable":
return 0
case "rc":
return 1
case "beta", "preview":
return 2
case "alpha", "dev":
return 3
default:
return 2 // unknown pre-release channels default to beta-level
}
}
maxRank := rank(requested)
return func(channel string, _ string) bool {
return rank(channel) <= maxRank
}
}
type platformTriple struct {
os string
arch string
libc string
}
// enumerateTriplets builds the ordered list of platform combinations to try.
// It uses CompatArches for arch fallback and tries multiple libc values.
func enumerateTriplets(osStr, archStr, libcStr string) []platformTriple {
// OS candidates: specific OS first, then POSIX compat, then any.
var oses []string
switch osStr {
case "windows":
oses = []string{"windows", "ANYOS", ""}
case "android":
oses = []string{"android", "linux", "posix_2024", "posix_2017", "ANYOS", ""}
case "":
oses = []string{"ANYOS", ""}
default:
oses = []string{osStr, "posix_2024", "posix_2017", "ANYOS", ""}
}
// Arch candidates: use CompatArches for fallback chain.
arches := buildmeta.CompatArches(buildmeta.OS(osStr), buildmeta.Arch(archStr))
var archStrs []string
for _, a := range arches {
archStrs = append(archStrs, string(a))
}
// Also try ANYARCH and empty (for platform-agnostic assets like git repos).
archStrs = append(archStrs, "ANYARCH", "")
// Libc candidates.
var libcs []string
if libcStr != "" {
libcs = []string{libcStr, "none", ""}
} else {
// No preference: try all common options.
switch osStr {
case "linux":
// none first (static, no deps), then gnu, musl, empty.
libcs = []string{"none", "gnu", "musl", ""}
case "windows":
// none first (no deps), msvc last (needs vcredist).
libcs = []string{"none", "msvc", ""}
default:
libcs = []string{"none", ""}
}
}
var triplets []platformTriple
for _, os := range oses {
for _, arch := range archStrs {
for _, libc := range libcs {
triplets = append(triplets, platformTriple{
os: os,
arch: arch,
libc: libc,
})
}
}
}
return triplets
}
// pickBest selects the best asset from a set of candidates for the same
// version and platform. Prefers the requested variant (or no-variant if
// none requested), then picks by format preference.
func pickBest(candidates []storage.Asset, formats []string, wantVariant string, ltsOnly bool) (storage.Asset, bool) {
// Filter by LTS if requested.
if ltsOnly {
var lts []storage.Asset
for _, a := range candidates {
if a.LTS {
lts = append(lts, a)
}
}
if len(lts) == 0 {
return storage.Asset{}, false
}
candidates = lts
}
// Separate into variant-matched and non-variant pools.
var preferred []storage.Asset
var fallback []storage.Asset
for _, a := range candidates {
if wantVariant != "" {
// User requested a specific variant.
if hasVariant(a.Variants, wantVariant) {
preferred = append(preferred, a)
} else if len(a.Variants) == 0 {
fallback = append(fallback, a)
}
} else {
// No variant requested: prefer no-variant assets.
if len(a.Variants) == 0 {
preferred = append(preferred, a)
} else {
fallback = append(fallback, a)
}
}
}
// Try preferred pool first, then fallback.
for _, pool := range [][]storage.Asset{preferred, fallback} {
if len(pool) == 0 {
continue
}
if best, ok := pickByFormat(pool, formats); ok {
return best, true
}
}
return storage.Asset{}, false
}
// pickByFormat selects the asset with the most preferred format.
func pickByFormat(assets []storage.Asset, formats []string) (storage.Asset, bool) {
for _, fmt := range formats {
for _, a := range assets {
if a.Format == fmt {
return a, true
}
}
}
// No format match — return the first asset as last resort.
if len(assets) > 0 {
return assets[0], true
}
return storage.Asset{}, false
}
func hasVariant(variants []string, want string) bool {
for _, v := range variants {
if v == want {
return true
}
}
return false
}
// defaultFormats returns the format preference order for an OS.
// zst is preferred as the modern standard, but availability varies.
func defaultFormats(os string) []string {
switch os {
case "windows":
return []string{
".tar.zst",
".tar.xz",
".zip",
".tar.gz",
".exe.xz",
".7z",
".exe",
".msi",
"git",
}
case "darwin":
return []string{
".tar.zst",
".tar.xz",
".zip",
".tar.gz",
".gz",
".app.zip",
".dmg",
".pkg",
"git",
}
default:
// Linux and other POSIX.
return []string{
".tar.zst",
".tar.xz",
".tar.gz",
".gz",
".zip",
".xz",
"git",
}
}
}