zsvo/pkg/deps/deps.go
2026-03-13 15:18:31 +03:00

414 lines
8.7 KiB
Go

package deps
import (
"fmt"
"strconv"
"strings"
"unicode"
)
// VersionOp defines version comparison operator for dependency constraints.
type VersionOp int
const (
OpAny VersionOp = iota
OpEqual
OpGreater
OpGreaterOrEqual
OpLess
OpLessOrEqual
)
// Constraint describes one dependency alternative like "pkg>=1.2".
type Constraint struct {
Name string
Op VersionOp
Version string
}
// Requirement describes one dependency expression with alternatives: "a | b>=2".
type Requirement struct {
Raw string
Alternatives []Constraint
}
// ParseRequirements parses dependency expressions.
func ParseRequirements(raw []string) ([]Requirement, error) {
reqs := make([]Requirement, 0, len(raw))
for _, entry := range raw {
req, err := ParseRequirement(entry)
if err != nil {
return nil, err
}
reqs = append(reqs, req)
}
return reqs, nil
}
// ParseRequirement parses one dependency expression.
func ParseRequirement(raw string) (Requirement, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Requirement{}, fmt.Errorf("dependency cannot be empty")
}
parts := strings.Split(raw, "|")
alts := make([]Constraint, 0, len(parts))
for _, part := range parts {
c, err := parseConstraint(part)
if err != nil {
return Requirement{}, fmt.Errorf("invalid dependency %q: %w", raw, err)
}
alts = append(alts, c)
}
if len(alts) == 0 {
return Requirement{}, fmt.Errorf("invalid dependency %q", raw)
}
return Requirement{
Raw: raw,
Alternatives: alts,
}, nil
}
func parseConstraint(raw string) (Constraint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Constraint{}, fmt.Errorf("empty alternative")
}
// Debian style: "pkg (>= 1.2)".
if open := strings.IndexByte(raw, '('); open >= 0 {
close := strings.LastIndexByte(raw, ')')
if close < open || close != len(raw)-1 {
return Constraint{}, fmt.Errorf("invalid parentheses syntax")
}
name := strings.TrimSpace(raw[:open])
if !isValidDepName(name) {
return Constraint{}, fmt.Errorf("invalid package name %q", name)
}
inner := strings.TrimSpace(raw[open+1 : close])
op, version, err := splitVersionConstraint(inner)
if err != nil {
return Constraint{}, err
}
return Constraint{Name: name, Op: op, Version: version}, nil
}
// Generic style: "pkg>=1.2" or "pkg >= 1.2" or plain "pkg".
name, op, version, err := splitNameAndConstraint(raw)
if err != nil {
return Constraint{}, err
}
return Constraint{Name: name, Op: op, Version: version}, nil
}
func splitNameAndConstraint(raw string) (string, VersionOp, string, error) {
name := strings.TrimSpace(raw)
op := OpAny
version := ""
idx, opFound := findOp(raw)
if opFound {
name = strings.TrimSpace(raw[:idx])
right := strings.TrimSpace(raw[idx:])
var err error
op, version, err = splitVersionConstraint(right)
if err != nil {
return "", OpAny, "", err
}
}
if !isValidDepName(name) {
return "", OpAny, "", fmt.Errorf("invalid package name %q", name)
}
return name, op, version, nil
}
func findOp(raw string) (int, bool) {
for i, r := range raw {
switch r {
case '<', '>', '=':
return i, true
}
}
return 0, false
}
func splitVersionConstraint(raw string) (VersionOp, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return OpAny, "", fmt.Errorf("empty version constraint")
}
opToken := ""
version := ""
switch {
case strings.HasPrefix(raw, ">="):
opToken = ">="
version = strings.TrimSpace(raw[2:])
case strings.HasPrefix(raw, "<="):
opToken = "<="
version = strings.TrimSpace(raw[2:])
case strings.HasPrefix(raw, "="):
opToken = "="
version = strings.TrimSpace(raw[1:])
case strings.HasPrefix(raw, ">"):
opToken = ">"
version = strings.TrimSpace(raw[1:])
case strings.HasPrefix(raw, "<"):
opToken = "<"
version = strings.TrimSpace(raw[1:])
default:
parts := strings.Fields(raw)
if len(parts) == 2 {
opToken = parts[0]
version = parts[1]
}
}
if opToken == "" || version == "" {
return OpAny, "", fmt.Errorf("invalid version constraint %q", raw)
}
if strings.HasPrefix(version, "<") || strings.HasPrefix(version, ">") || strings.HasPrefix(version, "=") {
return OpAny, "", fmt.Errorf("invalid version %q", version)
}
if strings.ContainsAny(version, "()|") {
return OpAny, "", fmt.Errorf("invalid version %q", version)
}
op, err := parseVersionOp(opToken)
if err != nil {
return OpAny, "", err
}
return op, version, nil
}
func parseVersionOp(raw string) (VersionOp, error) {
switch strings.TrimSpace(raw) {
case "=":
return OpEqual, nil
case ">":
return OpGreater, nil
case ">=":
return OpGreaterOrEqual, nil
case "<":
return OpLess, nil
case "<=":
return OpLessOrEqual, nil
default:
return OpAny, fmt.Errorf("unsupported operator %q", raw)
}
}
func isValidDepName(name string) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for i, r := range name {
if i == 0 {
if !unicode.IsDigit(r) && !unicode.IsLetter(r) {
return false
}
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '+', '.', '-', '_':
default:
return false
}
}
return true
}
// MatchesVersion checks whether package version satisfies this constraint.
func (c Constraint) MatchesVersion(installedVersion string) bool {
switch c.Op {
case OpAny:
return true
case OpEqual:
return CompareVersions(installedVersion, c.Version) == 0
case OpGreater:
return CompareVersions(installedVersion, c.Version) > 0
case OpGreaterOrEqual:
return CompareVersions(installedVersion, c.Version) >= 0
case OpLess:
return CompareVersions(installedVersion, c.Version) < 0
case OpLessOrEqual:
return CompareVersions(installedVersion, c.Version) <= 0
default:
return false
}
}
// CompareVersions compares package versions.
// Returns -1 if a < b, 0 if a == b, and 1 if a > b.
func CompareVersions(a, b string) int {
epochA, restA := splitEpoch(a)
epochB, restB := splitEpoch(b)
if epochA != epochB {
if epochA < epochB {
return -1
}
return 1
}
mainA, relA := splitRelease(restA)
mainB, relB := splitRelease(restB)
if c := compareVersionPart(mainA, mainB); c != 0 {
return c
}
return compareVersionPart(relA, relB)
}
func splitEpoch(raw string) (int64, string) {
raw = strings.TrimSpace(raw)
if idx := strings.IndexByte(raw, ':'); idx > 0 {
epochRaw := strings.TrimSpace(raw[:idx])
if epoch, err := strconv.ParseInt(epochRaw, 10, 64); err == nil {
return epoch, strings.TrimSpace(raw[idx+1:])
}
}
return 0, raw
}
func splitRelease(raw string) (string, string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "0", "0"
}
if idx := strings.LastIndex(raw, "-"); idx > 0 && idx < len(raw)-1 {
return raw[:idx], raw[idx+1:]
}
return raw, "0"
}
func compareVersionPart(a, b string) int {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
i, j := 0, 0
for i < len(a) || j < len(b) {
// Handle tilde comparison - tilde sorts before everything
aHasTilde := i < len(a) && a[i] == '~'
bHasTilde := j < len(b) && b[j] == '~'
switch {
case aHasTilde && bHasTilde:
// Both have tilde, compare the rest
i++
j++
continue
case aHasTilde:
return -1 // a has tilde, sorts before b
case bHasTilde:
return 1 // b has tilde, sorts before a
}
// Skip separators
for i < len(a) && isVersionSeparator(a[i]) {
i++
}
for j < len(b) && isVersionSeparator(b[j]) {
j++
}
if i >= len(a) && j >= len(b) {
return 0
}
if i >= len(a) {
return -1
}
if j >= len(b) {
return 1
}
aNum := isDigit(a[i])
bNum := isDigit(b[j])
segA, nextI := readVersionSegment(a, i, aNum)
segB, nextJ := readVersionSegment(b, j, bNum)
i, j = nextI, nextJ
var c int
switch {
case aNum && bNum:
c = compareNumericSegment(segA, segB)
case aNum && !bNum:
c = 1
case !aNum && bNum:
c = -1
default:
c = strings.Compare(strings.ToLower(segA), strings.ToLower(segB))
}
if c < 0 {
return -1
}
if c > 0 {
return 1
}
}
return 0
}
func isVersionSeparator(ch byte) bool {
if ch == '~' {
return false
}
return !isDigit(ch) && !isLetter(ch)
}
func readVersionSegment(s string, start int, numeric bool) (string, int) {
i := start
for i < len(s) {
ch := s[i]
if numeric {
if !isDigit(ch) {
break
}
} else {
if !isLetter(ch) {
break
}
}
i++
}
return s[start:i], i
}
func compareNumericSegment(a, b string) int {
a = strings.TrimLeft(a, "0")
b = strings.TrimLeft(b, "0")
if a == "" {
a = "0"
}
if b == "" {
b = "0"
}
if len(a) < len(b) {
return -1
}
if len(a) > len(b) {
return 1
}
return strings.Compare(a, b)
}
func isDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}
func isLetter(ch byte) bool {
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
}