213 lines
5.1 KiB
Go
213 lines
5.1 KiB
Go
package domain
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
var ErrInvalidSemVer = errors.New("invalid semantic version")
|
|
|
|
// SemVersion is a parsed Semantic Version 2.0.0 value.
|
|
type SemVersion struct {
|
|
original string
|
|
major string
|
|
minor string
|
|
patch string
|
|
prerelease []string
|
|
build []string
|
|
}
|
|
|
|
// ParseSemVer parses a complete Semantic Version 2.0.0 string.
|
|
func ParseSemVer(value string) (SemVersion, error) {
|
|
if value == "" {
|
|
return SemVersion{}, fmt.Errorf("%w: empty value", ErrInvalidSemVer)
|
|
}
|
|
|
|
coreAndPrerelease := value
|
|
var build []string
|
|
if separator := strings.IndexByte(value, '+'); separator >= 0 {
|
|
if strings.IndexByte(value[separator+1:], '+') >= 0 {
|
|
return SemVersion{}, fmt.Errorf("%w: multiple build separators", ErrInvalidSemVer)
|
|
}
|
|
coreAndPrerelease = value[:separator]
|
|
var err error
|
|
build, err = parseIdentifiers(value[separator+1:], false)
|
|
if err != nil {
|
|
return SemVersion{}, err
|
|
}
|
|
}
|
|
|
|
core := coreAndPrerelease
|
|
var prerelease []string
|
|
if separator := strings.IndexByte(coreAndPrerelease, '-'); separator >= 0 {
|
|
core = coreAndPrerelease[:separator]
|
|
var err error
|
|
prerelease, err = parseIdentifiers(coreAndPrerelease[separator+1:], true)
|
|
if err != nil {
|
|
return SemVersion{}, err
|
|
}
|
|
}
|
|
|
|
parts := strings.Split(core, ".")
|
|
if len(parts) != 3 {
|
|
return SemVersion{}, fmt.Errorf("%w: core must contain major.minor.patch", ErrInvalidSemVer)
|
|
}
|
|
for _, part := range parts {
|
|
if !validNumericIdentifier(part, false) {
|
|
return SemVersion{}, fmt.Errorf("%w: invalid core identifier %q", ErrInvalidSemVer, part)
|
|
}
|
|
}
|
|
|
|
return SemVersion{
|
|
original: value,
|
|
major: parts[0],
|
|
minor: parts[1],
|
|
patch: parts[2],
|
|
prerelease: prerelease,
|
|
build: build,
|
|
}, nil
|
|
}
|
|
|
|
// String returns the original normalized-by-validation input.
|
|
func (version SemVersion) String() string {
|
|
return version.original
|
|
}
|
|
|
|
// Compare applies SemVer precedence. Build metadata does not affect ordering.
|
|
func (version SemVersion) Compare(other SemVersion) int {
|
|
if comparison := compareNumericText(version.major, other.major); comparison != 0 {
|
|
return comparison
|
|
}
|
|
if comparison := compareNumericText(version.minor, other.minor); comparison != 0 {
|
|
return comparison
|
|
}
|
|
if comparison := compareNumericText(version.patch, other.patch); comparison != 0 {
|
|
return comparison
|
|
}
|
|
|
|
if len(version.prerelease) == 0 && len(other.prerelease) == 0 {
|
|
return 0
|
|
}
|
|
if len(version.prerelease) == 0 {
|
|
return 1
|
|
}
|
|
if len(other.prerelease) == 0 {
|
|
return -1
|
|
}
|
|
|
|
count := len(version.prerelease)
|
|
if len(other.prerelease) < count {
|
|
count = len(other.prerelease)
|
|
}
|
|
for index := 0; index < count; index++ {
|
|
left := version.prerelease[index]
|
|
right := other.prerelease[index]
|
|
leftNumeric := allDigits(left)
|
|
rightNumeric := allDigits(right)
|
|
switch {
|
|
case leftNumeric && rightNumeric:
|
|
if comparison := compareNumericText(left, right); comparison != 0 {
|
|
return comparison
|
|
}
|
|
case leftNumeric:
|
|
return -1
|
|
case rightNumeric:
|
|
return 1
|
|
case left < right:
|
|
return -1
|
|
case left > right:
|
|
return 1
|
|
}
|
|
}
|
|
switch {
|
|
case len(version.prerelease) < len(other.prerelease):
|
|
return -1
|
|
case len(version.prerelease) > len(other.prerelease):
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// CompareSemVer parses and compares two version strings.
|
|
func CompareSemVer(left, right string) (int, error) {
|
|
leftVersion, err := ParseSemVer(left)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
rightVersion, err := ParseSemVer(right)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return leftVersion.Compare(rightVersion), nil
|
|
}
|
|
|
|
func parseIdentifiers(value string, rejectNumericLeadingZero bool) ([]string, error) {
|
|
identifiers := strings.Split(value, ".")
|
|
if value == "" || len(identifiers) == 0 {
|
|
return nil, fmt.Errorf("%w: empty identifier list", ErrInvalidSemVer)
|
|
}
|
|
for _, identifier := range identifiers {
|
|
if identifier == "" {
|
|
return nil, fmt.Errorf("%w: empty identifier", ErrInvalidSemVer)
|
|
}
|
|
for _, character := range identifier {
|
|
if (character < '0' || character > '9') &&
|
|
(character < 'A' || character > 'Z') &&
|
|
(character < 'a' || character > 'z') &&
|
|
character != '-' {
|
|
return nil, fmt.Errorf(
|
|
"%w: invalid identifier %q",
|
|
ErrInvalidSemVer,
|
|
identifier,
|
|
)
|
|
}
|
|
}
|
|
if rejectNumericLeadingZero &&
|
|
allDigits(identifier) &&
|
|
!validNumericIdentifier(identifier, false) {
|
|
return nil, fmt.Errorf(
|
|
"%w: numeric prerelease identifier %q has a leading zero",
|
|
ErrInvalidSemVer,
|
|
identifier,
|
|
)
|
|
}
|
|
}
|
|
return identifiers, nil
|
|
}
|
|
|
|
func validNumericIdentifier(value string, allowLeadingZero bool) bool {
|
|
if value == "" || !allDigits(value) {
|
|
return false
|
|
}
|
|
return allowLeadingZero || len(value) == 1 || value[0] != '0'
|
|
}
|
|
|
|
func allDigits(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if character < '0' || character > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func compareNumericText(left, right string) int {
|
|
switch {
|
|
case len(left) < len(right):
|
|
return -1
|
|
case len(left) > len(right):
|
|
return 1
|
|
case left < right:
|
|
return -1
|
|
case left > right:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|