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 } }