Files
mesh-catalog/modules/gitea/cmd/npm-registry/semver.go
T
jochen a378abc758 gitea: say what keeps each npm version and delete only what nothing names (hq ADR 0251)
Every publish added a version and nothing removed one. A Go bundle beside the
TypeScript one keeps what a lockfile or a range on the forge names, what a
dist-tag names and the newest five; a dry run unless asked with a why, and
nothing deleted while any repository is unread.
2026-10-08 12:00:48 +02:00

363 lines
8.8 KiB
Go

package main
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// Versions and ranges as npm reads them (node-semver), small enough to read whole: a version, the
// order of two, and whether a version satisfies a range. Only what retention needs — which published
// version a range resolves to — and nothing npm does beyond that (no coercion, no loose mode).
// Version is one semantic version.
type Version struct {
Major, Minor, Patch int
Pre []string // the pre-release identifiers; none for a release
raw string
}
func (v Version) String() string { return v.raw }
var versionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
// ParseVersion reads a full version; a partial one (1.2) is not a version.
func ParseVersion(s string) (Version, error) {
s = strings.TrimSpace(s)
m := versionPattern.FindStringSubmatch(s)
if m == nil {
return Version{}, fmt.Errorf("%q is not a version", s)
}
v := Version{raw: strings.TrimPrefix(s, "v")}
v.Major, _ = strconv.Atoi(m[1])
v.Minor, _ = strconv.Atoi(m[2])
v.Patch, _ = strconv.Atoi(m[3])
if m[4] != "" {
v.Pre = strings.Split(m[4], ".")
}
return v, nil
}
// Compare is -1, 0 or 1 as a is lower than, equal to or higher than b, by semver precedence.
func Compare(a, b Version) int {
for _, d := range [][2]int{{a.Major, b.Major}, {a.Minor, b.Minor}, {a.Patch, b.Patch}} {
if d[0] != d[1] {
if d[0] < d[1] {
return -1
}
return 1
}
}
switch {
case len(a.Pre) == 0 && len(b.Pre) == 0:
return 0
case len(a.Pre) == 0:
return 1 // a release is higher than any of its pre-releases
case len(b.Pre) == 0:
return -1
}
for i := 0; i < len(a.Pre) && i < len(b.Pre); i++ {
if c := compareIdentifier(a.Pre[i], b.Pre[i]); c != 0 {
return c
}
}
switch {
case len(a.Pre) < len(b.Pre):
return -1
case len(a.Pre) > len(b.Pre):
return 1
}
return 0
}
func compareIdentifier(a, b string) int {
an, aerr := strconv.Atoi(a)
bn, berr := strconv.Atoi(b)
switch {
case aerr == nil && berr == nil:
switch {
case an < bn:
return -1
case an > bn:
return 1
}
return 0
case aerr == nil:
return -1 // a numeric identifier is lower than an alphanumeric one
case berr == nil:
return 1
}
return strings.Compare(a, b)
}
// comparator is one `<op> <version>`; ANY when the version is the zero bound of `*`.
type comparator struct {
op string // ">", ">=", "<", "<=", "="
v Version
}
func (c comparator) test(v Version) bool {
d := Compare(v, c.v)
switch c.op {
case ">":
return d > 0
case ">=":
return d >= 0
case "<":
return d < 0
case "<=":
return d <= 0
}
return d == 0
}
// Range is a set of comparator sets, any one of which a version must satisfy (`||`).
type Range struct {
sets [][]comparator
raw string
}
func (r Range) String() string { return r.raw }
// partial is a version that may stop early or carry x/X/*: -1 for a missing or wild part.
type partial struct {
major, minor, patch int
pre []string
}
var partialPattern = regexp.MustCompile(`^v?(\d+|[xX*])?(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
func parsePartial(s string) (partial, error) {
m := partialPattern.FindStringSubmatch(s)
if m == nil {
return partial{}, fmt.Errorf("%q is not a version or a partial one", s)
}
num := func(x string) int {
if x == "" || x == "x" || x == "X" || x == "*" {
return -1
}
n, _ := strconv.Atoi(x)
return n
}
p := partial{major: num(m[1]), minor: num(m[2]), patch: num(m[3])}
// Nothing below a wild part counts: 1.x.3 is 1.x.
if p.major < 0 {
p.minor, p.patch = -1, -1
} else if p.minor < 0 {
p.patch = -1
}
if m[4] != "" {
if p.patch < 0 {
return partial{}, fmt.Errorf("%q has a pre-release on a partial version", s)
}
p.pre = strings.Split(m[4], ".")
}
return p, nil
}
func ver(major, minor, patch int, pre ...string) Version {
raw := fmt.Sprintf("%d.%d.%d", major, minor, patch)
if len(pre) > 0 {
raw += "-" + strings.Join(pre, ".")
}
return Version{Major: major, Minor: minor, Patch: patch, Pre: pre, raw: raw}
}
// lower is the lowest version a partial names; upperExclusive the first version above it (with -0, so
// no pre-release of that next version slips in), and whether it has one.
func (p partial) lower() Version {
switch {
case p.major < 0:
return ver(0, 0, 0)
case p.minor < 0:
return ver(p.major, 0, 0)
case p.patch < 0:
return ver(p.major, p.minor, 0)
}
return ver(p.major, p.minor, p.patch, p.pre...)
}
func (p partial) upperExclusive() (Version, bool) {
switch {
case p.major < 0:
return Version{}, false
case p.minor < 0:
return ver(p.major+1, 0, 0, "0"), true
case p.patch < 0:
return ver(p.major, p.minor+1, 0, "0"), true
}
return Version{}, false
}
var (
hyphenPattern = regexp.MustCompile(`^\s*(\S+)\s+-\s+(\S+)\s*$`)
opSpace = regexp.MustCompile(`(>=|<=|>|<|=|\^|~>?)\s+`)
)
// ParseRange reads a range as npm does: exact, `^`, `~`, x-ranges, comparisons, hyphen ranges, sets
// joined by spaces, and `||`.
func ParseRange(s string) (Range, error) {
r := Range{raw: s}
for _, part := range strings.Split(s, "||") {
set, err := parseSet(strings.TrimSpace(part))
if err != nil {
return Range{}, err
}
r.sets = append(r.sets, set)
}
return r, nil
}
func parseSet(s string) ([]comparator, error) {
if m := hyphenPattern.FindStringSubmatch(s); m != nil {
from, err := parsePartial(m[1])
if err != nil {
return nil, err
}
to, err := parsePartial(m[2])
if err != nil {
return nil, err
}
set := []comparator{{">=", from.lower()}}
if up, has := to.upperExclusive(); has {
set = append(set, comparator{"<", up})
} else if to.major >= 0 {
set = append(set, comparator{"<=", to.lower()})
}
return set, nil
}
s = opSpace.ReplaceAllString(s, "$1")
if s == "" {
return []comparator{{">=", ver(0, 0, 0)}}, nil
}
var set []comparator
for _, word := range strings.Fields(s) {
cs, err := parseComparator(word)
if err != nil {
return nil, err
}
set = append(set, cs...)
}
return set, nil
}
func parseComparator(w string) ([]comparator, error) {
var op string
for _, o := range []string{">=", "<=", "~>", ">", "<", "=", "^", "~"} {
if strings.HasPrefix(w, o) {
op, w = o, w[len(o):]
break
}
}
p, err := parsePartial(w)
if err != nil {
return nil, err
}
lo := p.lower()
up, bounded := p.upperExclusive()
switch op {
case "", "=":
if !bounded && p.major >= 0 {
return []comparator{{"=", lo}}, nil
}
return xrange(lo, up, bounded), nil
case "^":
var hi Version
switch {
case p.major < 0:
return []comparator{{">=", ver(0, 0, 0)}}, nil
case p.major > 0 || p.minor < 0:
hi = ver(p.major+1, 0, 0, "0")
case p.minor > 0 || p.patch < 0:
hi = ver(0, p.minor+1, 0, "0")
default:
hi = ver(0, 0, p.patch+1, "0")
}
return []comparator{{">=", lo}, {"<", hi}}, nil
case "~", "~>":
var hi Version
switch {
case p.major < 0:
return []comparator{{">=", ver(0, 0, 0)}}, nil
case p.minor < 0:
hi = ver(p.major+1, 0, 0, "0")
default:
hi = ver(p.major, p.minor+1, 0, "0")
}
return []comparator{{">=", lo}, {"<", hi}}, nil
case ">":
if p.major < 0 {
return []comparator{{"<", ver(0, 0, 0, "0")}}, nil // nothing
}
if bounded {
return []comparator{{">=", up}}, nil
}
return []comparator{{">", lo}}, nil
case ">=":
return []comparator{{">=", lo}}, nil
case "<":
if p.major < 0 {
return []comparator{{"<", ver(0, 0, 0, "0")}}, nil
}
return []comparator{{"<", lo}}, nil
case "<=":
if p.major < 0 {
return []comparator{{">=", ver(0, 0, 0)}}, nil
}
if bounded {
return []comparator{{"<", up}}, nil
}
return []comparator{{"<=", lo}}, nil
}
return nil, fmt.Errorf("%q is not a comparator", w)
}
func xrange(lo, up Version, bounded bool) []comparator {
if !bounded {
return []comparator{{">=", lo}}
}
return []comparator{{">=", lo}, {"<", up}}
}
// Satisfies is whether v is in the range. A pre-release satisfies a set only when one of its
// comparators names the same major.minor.patch with a pre-release of its own — npm's rule, so that
// `^1.2.0` never resolves to 1.3.0-beta.
func (r Range) Satisfies(v Version) bool {
for _, set := range r.sets {
if testSet(set, v) {
return true
}
}
return false
}
func testSet(set []comparator, v Version) bool {
for _, c := range set {
if !c.test(v) {
return false
}
}
if len(v.Pre) == 0 {
return true
}
for _, c := range set {
if len(c.v.Pre) > 0 && c.v.Major == v.Major && c.v.Minor == v.Minor && c.v.Patch == v.Patch {
return true
}
}
return false
}
// MaxSatisfying is the highest of the versions in the range; false when none is.
func MaxSatisfying(r Range, versions []Version) (Version, bool) {
var best Version
found := false
for _, v := range versions {
if r.Satisfies(v) && (!found || Compare(v, best) > 0) {
best, found = v, true
}
}
return best, found
}