pkg/version/version_parser.go (236 lines of code) (raw):

// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one // or more contributor license agreements. Licensed under the Elastic License 2.0; // you may not use this file except in compliance with the Elastic License 2.0. package version import ( "errors" "fmt" "regexp" "slices" "strconv" "strings" ) // semVerFormat is a regexp taken from https://semver.org/ (see the FAQ section/Is there a suggested regular expression (RegEx) to check a SemVer string?) const semVerFormat = `^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$` const numericPrereleaseTokenFormat = `\d+` const preReleaseSeparator = "-" const metadataSeparator = "+" const prereleaseTokenSeparator = "." const snapshotPrereleaseToken = "SNAPSHOT" const IsIndependentReleaseFormat = `^build\d{12}` var semVerFmtRegEx *regexp.Regexp var numericPrereleaseTokenRegEx *regexp.Regexp var namedGroups map[string]int func init() { // small init to compile the regex and build a map of named groups and indexes semVerFmtRegEx = regexp.MustCompile(semVerFormat) groups := semVerFmtRegEx.SubexpNames() namedGroups = make(map[string]int, len(groups)) for i, groupName := range groups { namedGroups[groupName] = i } // compile the numeric prerelease token regex numericPrereleaseTokenRegEx = regexp.MustCompile(numericPrereleaseTokenFormat) } var ErrNoMatch = errors.New("version string does not match expected format") type ParsedSemVer struct { original string major int minor int patch int prerelease string buildMetadata string } func (psv ParsedSemVer) Original() string { return psv.original } func (psv ParsedSemVer) Major() int { return psv.major } func (psv ParsedSemVer) Minor() int { return psv.minor } func (psv ParsedSemVer) Patch() int { return psv.patch } func (psv ParsedSemVer) CoreVersion() string { return fmt.Sprintf("%d.%d.%d", psv.Major(), psv.Minor(), psv.Patch()) } func (psv ParsedSemVer) Prerelease() string { return psv.prerelease } func (psv ParsedSemVer) PrereleaseTokens() []string { if len(psv.prerelease) == 0 { return nil } return strings.Split(psv.Prerelease(), prereleaseTokenSeparator) } func (psv ParsedSemVer) BuildMetadata() string { return psv.buildMetadata } func (psv ParsedSemVer) VersionWithPrerelease() string { b := new(strings.Builder) b.WriteString(psv.CoreVersion()) if psv.prerelease != "" { b.WriteString(preReleaseSeparator) b.WriteString(psv.prerelease) } return b.String() } func (psv ParsedSemVer) ExtractSnapshotFromVersionString() (string, bool) { b := new(strings.Builder) b.WriteString(psv.CoreVersion()) prereleaseTokens := psv.PrereleaseTokens() isSnapshot := false for i, t := range prereleaseTokens { if t == snapshotPrereleaseToken { // we found the snapshot prerelease qualifier (we assume there's only 1) isSnapshot = true prereleaseTokens = append(prereleaseTokens[:i], prereleaseTokens[i+1:]...) break } } if len(prereleaseTokens) > 0 { b.WriteString(preReleaseSeparator) b.WriteString(assemblePrereleaseStringFromTokens(prereleaseTokens)) } if len(psv.buildMetadata) > 0 { b.WriteString(metadataSeparator) b.WriteString(psv.buildMetadata) } return b.String(), isSnapshot } func (psv ParsedSemVer) IsSnapshot() bool { prereleaseTokens := psv.PrereleaseTokens() return slices.Contains(prereleaseTokens, snapshotPrereleaseToken) } func (psv ParsedSemVer) IsIndependentRelease() bool { matched, err := regexp.MatchString(IsIndependentReleaseFormat, psv.buildMetadata) return err == nil && matched } func (psv ParsedSemVer) IndependentBuildID() string { r := regexp.MustCompile(IsIndependentReleaseFormat) if matches := r.FindAllString(psv.buildMetadata, -1); len(matches) > 0 { return matches[0] } return "" } func (psv ParsedSemVer) Less(other ParsedSemVer) bool { // compare major version if psv.major != other.major { return psv.major < other.major } //same major, check minor if psv.minor != other.minor { return psv.minor < other.minor } //same minor, check patch if psv.patch != other.patch { return psv.patch < other.patch } if psv.IsIndependentRelease() || other.IsIndependentRelease() { // one of them is independent release let's compare those return psv.compareIndependentBuild(other) } // compare prerelease strings as major.minor.patch are equal return psv.comparePrerelease(other) } func (psv ParsedSemVer) compareIndependentBuild(other ParsedSemVer) bool { // spare regex parsing psvIsIndependent := psv.IsIndependentRelease() otherIsIndependent := other.IsIndependentRelease() if !psvIsIndependent && !otherIsIndependent { return false } if psvIsIndependent != otherIsIndependent { // independent release is always newer return !psvIsIndependent } // compare build IDs return psv.IndependentBuildID() < other.IndependentBuildID() } // comparePrerelease compares the prerelease part of 2 ParsedSemVer objects // the return value must conform to psv.prerelease < other.prerelease following comparison rules from https://semver.org/ func (psv ParsedSemVer) comparePrerelease(other ParsedSemVer) bool { // last resort before parsing prerelease: check if one is prerelease and the other isn't if psv.prerelease != "" && other.prerelease == "" { return true } if psv.prerelease == "" && other.prerelease != "" { return false } // tokenize prereleases and compare them prereleaseTokens := strings.Split(psv.prerelease, prereleaseTokenSeparator) otherPrereleaseTokens := strings.Split(other.prerelease, prereleaseTokenSeparator) // compute the min amount of tokens minPrereleaseTokens := len(prereleaseTokens) if len(otherPrereleaseTokens) < minPrereleaseTokens { minPrereleaseTokens = len(otherPrereleaseTokens) } for i := 0; i < minPrereleaseTokens; i++ { token := prereleaseTokens[i] otherToken := otherPrereleaseTokens[i] isTokenNumeric := numericPrereleaseTokenRegEx.MatchString(token) isOtherTokenNumeric := numericPrereleaseTokenRegEx.MatchString(otherToken) // numeric identifiers always have lower precedence than non-numeric identifiers if isTokenNumeric && !isOtherTokenNumeric { return true } if !isTokenNumeric && isOtherTokenNumeric { return false } // prerelease tokens are of the same type: check if we have to compare them as numbers or strings if isTokenNumeric { // we can ignore the error as the regex we are using is even more restrictive than a generic integer regex numericToken, _ := strconv.Atoi(token) otherNumericToken, _ := strconv.Atoi(otherToken) if numericToken != otherNumericToken { return numericToken < otherNumericToken } } else { // compare them as strings if token != otherToken { return token < otherToken } } } // the minimum number of tokens is the same across the two versions, check if one of the two have more tokens return len(prereleaseTokens) < len(otherPrereleaseTokens) } func (psv ParsedSemVer) String() string { b := new(strings.Builder) b.WriteString(psv.CoreVersion()) if psv.Prerelease() != "" { b.WriteString(preReleaseSeparator) b.WriteString(psv.Prerelease()) } if psv.BuildMetadata() != "" { b.WriteString(metadataSeparator) b.WriteString(psv.buildMetadata) } return b.String() } func NewParsedSemVer(major int, minor int, patch int, prerelease string, metadata string) *ParsedSemVer { return &ParsedSemVer{ major: major, minor: minor, patch: patch, prerelease: prerelease, buildMetadata: metadata, } } func ParseVersion(version string) (*ParsedSemVer, error) { matches := semVerFmtRegEx.FindStringSubmatch(strings.TrimSpace(version)) if matches == nil { return nil, ErrNoMatch } major, err := strconv.Atoi(matches[namedGroups["major"]]) if err != nil { return nil, fmt.Errorf("parsing major version: %w", err) } minor, err := strconv.Atoi(matches[namedGroups["minor"]]) if err != nil { return nil, fmt.Errorf("parsing minor version: %w", err) } patch, err := strconv.Atoi(matches[namedGroups["patch"]]) if err != nil { return nil, fmt.Errorf("parsing patch version: %w", err) } return &ParsedSemVer{ original: version, major: major, minor: minor, patch: patch, prerelease: matches[namedGroups["prerelease"]], buildMetadata: matches[namedGroups["buildmetadata"]], }, nil } func assemblePrereleaseStringFromTokens(tokens []string) string { builder := new(strings.Builder) for _, t := range tokens { if builder.Len() > 0 { builder.WriteString(prereleaseTokenSeparator) } builder.WriteString(t) } return builder.String() } type SortableParsedVersions []*ParsedSemVer func (spv SortableParsedVersions) Len() int { return len(spv) } func (spv SortableParsedVersions) Swap(i, j int) { spv[i], spv[j] = spv[j], spv[i] } func (spv SortableParsedVersions) Less(i, j int) bool { return spv[i].Less(*spv[j]) }