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.
363 lines
8.8 KiB
Go
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
|
|
}
|