diff --git a/CHANGELOG.md b/CHANGELOG.md index 362343f..66acba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,24 @@ Date format: `YYYY-MM-DD` ### Fixed ### Security +--- +## [1.1.0] - 2024-10-25 + +### Added +- **FEATURE:** Added support for a `Parsing` interface to allow for custom version parsing. +- **FEATURE:** Added documentation for the configuration options: `WithStrictAdherence`. + +### Changed +- **DEBT:** Added additional functional tests to improve code coverage. +- **DEBT:** Refactored the benchmark tests to correctly measure performance. + +### Deprecated +### Removed +### Fixed +- **DEFECT:** Corrected various documentation issues. + +### Security + --- ## [1.0.0] - 2024-10-24 @@ -28,7 +46,8 @@ Date format: `YYYY-MM-DD` ### Fixed ### Security -[Unreleased]: https://github.com/sixafter/semver/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/sixafter/semver/compare/v1.1.0...HEAD +[1.0.0]: https://github.com/sixafter/semver/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/sixafter/semver/compare/d63ed577e7e841fb2209cfdcf4269fac6a57c85e...v1.0.0 [MUST]: https://datatracker.ietf.org/doc/html/rfc2119 diff --git a/README.md b/README.md index 18ad095..6185e63 100644 --- a/README.md +++ b/README.md @@ -15,60 +15,57 @@ A Semantic Versioning 2.0.0 compliant parser and utility library written in Go. The `semver` library offers a comprehensive and efficient solution for working with Semantic Versioning 2.0.0. Key features include: -### Zero Dependencies -- Lightweight implementation with no external dependencies beyond the standard library. +- **Zero Dependencies** + - Lightweight implementation with no external dependencies beyond the standard library. -### Full Compliance -- Parses and validates semantic versions according to the [Semantic Versioning 2.0.0](https://semver.org) specification. +- **Full Compliance** + - Parses and validates semantic versions according to the [Semantic Versioning 2.0.0](https://semver.org) specification. -### Version Parsing and Validation -- Parse semantic version strings into structured `Version` objects. -- Automatically validate version strings for correctness, including: +- **Version Parsing and Validation** + - Parses semantic version strings into structured `Version` objects. + - Automatically validates version strings for correctness, including: - Major, minor, and patch components. - Pre-release identifiers (e.g., `alpha`, `beta`, `rc.1`). - Build metadata (e.g., `build.123`). - Enforces no leading zeroes in numeric components. -### Version Comparison -- Supports comparison of versions using Semantic Versioning rules: +- **Customizable** + - Define your own parser. + - Strict mode for enforcing strict version format rules. + +- **Version Comparison** + - Supports comparison of versions using Semantic Versioning rules: - `Compare`: Returns -1, 0, or 1 for less than, equal to, or greater than comparisons. - - Convenient helper methods: - - `LessThan`, `LessThanOrEqual` - - `GreaterThan`, `GreaterThanOrEqual` - - `Equal` -- Correctly handles precedence rules for pre-release versions and build metadata. + - Convenient helper methods: `LessThan`, `LessThanOrEqual`, `GreaterThan`, `GreaterThanOrEqual`, `Equal`. + - Correctly handles precedence rules for pre-release versions and build metadata. -### Version Ranges -- Flexible range functionality for evaluating version constraints: +- **Version Ranges** + - Flexible range functionality for evaluating version constraints: - Define complex version ranges using a familiar syntax (e.g., `">=1.0.0 <2.0.0"`). - Determine whether a version satisfies a given range. - Combine multiple ranges for advanced constraints (e.g., `">=1.2.3 || <1.0.0-alpha"`). -- Useful for dependency management, release gating, and compatibility checks. - -### Version Construction -- Create `Version` instances programmatically using the `NewVersion` constructor. -- Supports detailed customization of pre-release identifiers and build metadata. + - Useful for dependency management, release gating, and compatibility checks. -### JSON Support -- Seamlessly marshal and unmarshal `Version` objects to and from JSON. -- Works with `encoding/json` for easy integration with APIs and configuration files. +- **JSON Support** + - Seamlessly marshal and unmarshal `Version` objects to and from JSON. + - Works with `encoding/json` for easy integration with APIs and configuration files. -### Database Support -- Compatible with `database/sql`: +- **Database Support** + - Compatible with `database/sql`: - Implements `driver.Valuer` to store `Version` in databases. - Implements `sql.Scanner` to retrieve `Version` from databases. -### Encoding and Decoding -- Implements standard Go interfaces: +- **Encoding and Decoding** + - Implements standard Go interfaces: - `encoding.TextMarshaler` and `encoding.TextUnmarshaler` for text encoding. - `encoding.BinaryMarshaler` and `encoding.BinaryUnmarshaler` for binary encoding. -### Performance Optimizations -- Efficient parsing and comparison with minimal memory allocations. -- Designed for high performance with concurrent workloads. +- **Performance Optimizations** + - Efficient parsing and comparison with minimal memory allocations. + - Designed for high performance with concurrent workloads. -### Well-Tested -- Comprehensive test coverage, including: +- **Well-Tested** + - Comprehensive test coverage, including: - Functional tests for all features. - Benchmarks to validate performance optimizations. - Detailed tests for range evaluation, parsing, and edge cases. @@ -209,13 +206,11 @@ goos: darwin goarch: arm64 pkg: github.com/sixafter/semver cpu: Apple M2 Ultra -BenchmarkParseVersionSerial-24 1577372 744.4 ns/op 608 B/op 16 allocs/op -BenchmarkParseVersionConcurrent-24 3696235 337.3 ns/op 608 B/op 16 allocs/op -BenchmarkParseVersionAllocations-24 7339026 162.1 ns/op 160 B/op 4 allocs/op -BenchmarkParseVersionLargeSerial-24 208 5825681 ns/op 4640079 B/op 120000 allocs/op -BenchmarkParseVersionLargeConcurrent-24 589 2047877 ns/op 4640185 B/op 120000 allocs/op +BenchmarkParseVersionSerial-24 9170428 123.1 ns/op 128 B/op 2 allocs/op +BenchmarkParseVersionConcurrent-24 17886765 68.35 ns/op 128 B/op 2 allocs/op +BenchmarkParseVersionAllocations-24 7576131 157.2 ns/op 144 B/op 4 allocs/op PASS -ok github.com/sixafter/semver 8.366s +ok github.com/sixafter/semver 4.109s ``` diff --git a/config.go b/config.go new file mode 100644 index 0000000..63908b3 --- /dev/null +++ b/config.go @@ -0,0 +1,111 @@ +// Copyright (c) 2024 Six After, Inc +// +// This source code is licensed under the Apache 2.0 License found in the +// LICENSE file in the root directory of this source tree. + +package semver + +// ConfigOptions holds the configurable options for the Parser. +// It is used with the Function Options pattern. +type ConfigOptions struct { + Strict bool +} + +// Config holds the runtime configuration for the parser. +// +// It is immutable after initialization. +type Config interface { + // StrictAdherence returns a boolean value indicating whether strict adherence is enabled. + // This method is used to determine if the configuration should follow strict rules, + // such as requiring full compliance with the Semantic Versioning specification. + // When enabled, parsing or processing might be more stringent, rejecting inputs that + // do not fully comply with the expected standards. + // + // Returns: + // - bool: true if strict adherence is enabled, false otherwise. + // + // Example usage: + // + // var config Config = NewConfig() + // if config.StrictAdherence() { + // fmt.Println("Strict adherence is enabled.") + // } else { + // fmt.Println("Strict adherence is disabled.") + // } + StrictAdherence() bool +} + +// Configuration defines the interface for retrieving parser configuration. +type Configuration interface { + // Config returns the runtime configuration of the parser. + Config() Config +} + +type runtimeConfig struct { + strict bool +} + +// Option defines a function type for configuring the Parser. +// It allows for flexible and extensible configuration by applying +// various settings to the ConfigOptions during Parser initialization. +type Option func(*ConfigOptions) + +// WithStrictAdherence sets the strict adherence value for the configuration. +// This option can be used to enable or disable strict mode, which affects the way +// certain rules are enforced during parsing or processing. +// +// Setting strict adherence to true can be used to enforce more rigid compliance +// with versioning rules or configuration standards. When set to false, the parser +// may allow some flexibility in handling certain inputs. +// +// Parameters: +// - value: A boolean indicating whether strict adherence should be enabled (true) or disabled (false). +// +// Returns: +// - Option: A functional option that can be passed to a configuration function to modify behavior. +// +// Example usage: +// +// parser, err := NewParser(WithStrictAdherence(true)) +// if err != nil { +// log.Fatalf("Failed to create parser: %v", err) +// } +// +// // Use the parser with strict adherence enabled +// version, err := parser.Parse("1.0.0") +// if err != nil { +// log.Fatalf("Failed to parse version: %v", err) +// } +// fmt.Printf("Parsed version: %v\n", version) +func WithStrictAdherence(value bool) Option { + return func(o *ConfigOptions) { + o.Strict = value + } +} + +// StrictAdherence returns a boolean value indicating whether strict adherence is enabled. +// This method is used to determine if the configuration should follow strict rules, +// such as requiring full compliance with the Semantic Versioning specification. +// When enabled, parsing or processing might be more stringent, rejecting inputs that +// do not fully comply with the expected standards. +// +// Returns: +// - bool: true if strict adherence is enabled, false otherwise. +// +// Example usage: +// +// var config Config = NewConfig() +// if config.StrictAdherence() { +// fmt.Println("Strict adherence is enabled.") +// } else { +// fmt.Println("Strict adherence is disabled.") +// } +func (c *runtimeConfig) StrictAdherence() bool { + return c.strict +} + +func buildRuntimeConfig(opts *ConfigOptions) (*runtimeConfig, error) { + return &runtimeConfig{ + strict: opts.Strict, + }, nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..e1c4d83 --- /dev/null +++ b/config_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2024 Six After, Inc +// +// This source code is licensed under the Apache 2.0 License found in the +// LICENSE file in the root directory of this source tree. + +package semver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestGetConfig tests the Config() method of the generator. +func TestGetConfig(t *testing.T) { + t.Parallel() + is := assert.New(t) + + gen, err := NewParser(WithStrictAdherence(true)) + is.NoError(err, "NewGenerator() should not return an error with the default alphabet") + + // Assert that generator implements Configuration interface + config, ok := gen.(Configuration) + is.True(ok, "Parser should implement Configuration interface") + + runtimeConfig := config.Config() + + is.True(runtimeConfig.StrictAdherence(), "Config.StrictAdherence should be true") +} diff --git a/marshalers.go b/marshalers.go new file mode 100644 index 0000000..572c69a --- /dev/null +++ b/marshalers.go @@ -0,0 +1,147 @@ +// Copyright (c) 2024 Six After, Inc +// +// This source code is licensed under the Apache 2.0 License found in the +// LICENSE file in the root directory of this source tree. + +package semver + +import ( + "database/sql/driver" + "encoding/json" +) + +// MarshalText implements encoding.TextMarshaler. +// It returns the string representation of the Version. +// +// Example: +// +// v := semver.MustParse("1.2.3-alpha+build.456") +// text, err := v.MarshalText() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(string(text)) // Output: 1.2.3-alpha+build.456 +func (v Version) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. +// It parses the given text into a Version. +// +// Example: +// +// var v semver.Version +// err := v.UnmarshalText([]byte("1.2.3-alpha+build.456")) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(v) // Output: 1.2.3-alpha+build.456 +func (v *Version) UnmarshalText(text []byte) error { + parsed, err := Parse(string(text)) + if err != nil { + return err + } + *v = parsed + return nil +} + +// MarshalBinary implements encoding.BinaryMarshaler. +// It returns the binary encoding of the Version. +// +// Example: +// +// v := semver.MustParse("1.2.3-alpha") +// binaryData, err := v.MarshalBinary() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("%s\n", binaryData) // Output: 1.2.3-alpha +func (v Version) MarshalBinary() ([]byte, error) { + return v.MarshalText() +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler. +// It decodes the given binary data into a Version. +// +// Example: +// +// var v semver.Version +// err := v.UnmarshalBinary([]byte("1.2.3+build.123")) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(v) // Output: 1.2.3+build.123 +func (v *Version) UnmarshalBinary(data []byte) error { + return v.UnmarshalText(data) +} + +// MarshalJSON implements json.Marshaler. +// It returns the JSON encoding of the Version. +// +// Example: +// +// v := semver.MustParse("1.2.3-beta") +// jsonData, err := v.MarshalJSON() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(string(jsonData)) // Output: "1.2.3-beta" +func (v Version) MarshalJSON() ([]byte, error) { + return json.Marshal(v.String()) +} + +// UnmarshalJSON implements json.Unmarshaler. +// It decodes JSON data into a Version. +// +// Example: +// +// var v semver.Version +// err := v.UnmarshalJSON([]byte("\"1.2.3-beta+build\"")) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(v) // Output: 1.2.3-beta+build +func (v *Version) UnmarshalJSON(data []byte) error { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + return v.UnmarshalText([]byte(text)) +} + +// Value implements database/sql/driver.Valuer. +// It returns the string representation of the Version as a database value. +// +// Example: +// +// v := semver.MustParse("1.2.3-alpha") +// dbValue, err := v.Value() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(dbValue) // Output: 1.2.3-alpha +func (v Version) Value() (driver.Value, error) { + return v.String(), nil +} + +// Scan implements database/sql.Scanner. +// It scans a database value into a Version. +// +// Example: +// +// var v semver.Version +// err := v.Scan("1.2.3-alpha+build") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(v) // Output: 1.2.3-alpha+build +func (v *Version) Scan(value interface{}) error { + switch t := value.(type) { + case string: + return v.UnmarshalText([]byte(t)) + case []byte: + return v.UnmarshalText(t) + default: + return ErrUnsupportedType + } +} diff --git a/marshalers_test.go b/marshalers_test.go new file mode 100644 index 0000000..cc966d9 --- /dev/null +++ b/marshalers_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2024 Six After, Inc +// +// This source code is licensed under the Apache 2.0 License found in the +// LICENSE file in the root directory of this source tree. + +package semver + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVersionMarshalText(t *testing.T) { + t.Parallel() + v := MustParse("1.2.3-alpha+build.123") + text, err := v.MarshalText() + + is := assert.New(t) + is.NoError(err) + is.Equal("1.2.3-alpha+build.123", string(text)) +} + +func TestVersionUnmarshalText(t *testing.T) { + t.Parallel() + var v Version + err := v.UnmarshalText([]byte("1.2.3-alpha+build.123")) + + is := assert.New(t) + is.NoError(err) + is.Equal(MustParse("1.2.3-alpha+build.123"), v) +} + +func TestVersionMarshalBinary(t *testing.T) { + t.Parallel() + v := MustParse("1.2.3-beta") + binaryData, err := v.MarshalBinary() + + is := assert.New(t) + is.NoError(err) + is.Equal([]byte("1.2.3-beta"), binaryData) +} + +func TestVersionUnmarshalBinary(t *testing.T) { + t.Parallel() + var v Version + err := v.UnmarshalBinary([]byte("1.2.3+build.456")) + + is := assert.New(t) + is.NoError(err) + is.Equal(MustParse("1.2.3+build.456"), v) +} + +func TestVersionMarshalJSON(t *testing.T) { + t.Parallel() + v := MustParse("1.2.3-alpha") + jsonData, err := json.Marshal(v) + + is := assert.New(t) + is.NoError(err) + is.JSONEq(`"1.2.3-alpha"`, string(jsonData)) +} + +func TestVersionUnmarshalJSON(t *testing.T) { + t.Parallel() + var v Version + err := json.Unmarshal([]byte(`"1.2.3-beta+build.789"`), &v) + + is := assert.New(t) + is.NoError(err) + is.Equal(MustParse("1.2.3-beta+build.789"), v) +} + +func TestVersionValue(t *testing.T) { + t.Parallel() + v := MustParse("1.2.3-alpha") + dbValue, err := v.Value() + + is := assert.New(t) + is.NoError(err) + is.Equal("1.2.3-alpha", dbValue) +} + +func TestVersionScan(t *testing.T) { + t.Parallel() + var v Version + is := assert.New(t) + + // Test with string + err := v.Scan("1.2.3-alpha+build.123") + is.NoError(err) + is.Equal(MustParse("1.2.3-alpha+build.123"), v) + + // Test with []byte + err = v.Scan([]byte("1.2.3-beta")) + is.NoError(err) + is.Equal(MustParse("1.2.3-beta"), v) + + // Test with unsupported type + err = v.Scan(123) + is.Error(err) + is.EqualError(err, "unsupported type for Version") +} diff --git a/prerelease_version.go b/prerelease_version.go index 50116f3..720a11d 100644 --- a/prerelease_version.go +++ b/prerelease_version.go @@ -6,19 +6,10 @@ package semver import ( - "errors" - "fmt" "strconv" "strings" ) -// Error messages for prerelease validation. -var ( - ErrEmptyPrerelease = errors.New("prerelease is empty") - ErrLeadingZeroInNumeric = "numeric prerelease version must not contain leading zeroes: %q" - ErrInvalidPrereleaseChars = "invalid character(s) found in prerelease: %q" -) - // PrereleaseVersion represents a semantic version prerelease identifier. // // A prerelease version can be either numeric or alphanumeric. @@ -47,40 +38,25 @@ type PrereleaseVersion struct { // if err != nil { // fmt.Println("Error:", err) // Output: numeric prerelease version must not contain leading zeroes: "01" // } -func NewPrereleaseVersion(s string) (PrereleaseVersion, error) { - if len(s) == 0 { - return PrereleaseVersion{}, errors.New("prerelease is empty") +func NewPrereleaseVersion(part string) (PrereleaseVersion, error) { + if len(part) == 0 { + return PrereleaseVersion{}, ErrEmptyPrereleaseIdentifier } - // Check if the string contains only numbers - if containsOnlyNumbers(s) { - // Check for leading zeroes - if len(s) > 1 && s[0] == '0' { - return PrereleaseVersion{}, fmt.Errorf("numeric prerelease version must not contain leading zeroes: %q", s) + if isNumeric(part) { + if part[0] == '0' && len(part) > 1 { + return PrereleaseVersion{}, ErrLeadingZeroInNumericIdentifier } - // Parse numeric string - number, err := strconv.ParseUint(s, 10, 64) + x, err := strconv.ParseUint(part, 10, 64) if err != nil { - return PrereleaseVersion{}, err + return PrereleaseVersion{}, ErrInvalidNumericIdentifier } - return PrereleaseVersion{ - partNumeric: number, - isNumeric: true, - }, nil - } - - // Check if the string contains only alphanumeric characters - if containsOnlyAlphanumeric(s) { - return PrereleaseVersion{ - partString: s, - isNumeric: false, - }, nil + return PrereleaseVersion{partNumeric: x, isNumeric: true}, nil } - // If neither numeric nor alphanumeric, return an error - return PrereleaseVersion{}, fmt.Errorf("invalid character(s) found in prerelease: %q", s) + return PrereleaseVersion{partString: part, isNumeric: false}, nil } // IsNumeric checks if the prerelease version is numeric. @@ -152,46 +128,6 @@ func (v PrereleaseVersion) String() string { if v.isNumeric { return strconv.FormatUint(v.partNumeric, 10) } - return v.partString -} - -// containsOnlyNumbers checks if the string contains only numeric characters. -// -// Example: -// -// fmt.Println(containsOnlyNumbers("123")) // Output: true -// fmt.Println(containsOnlyNumbers("abc")) // Output: false -func containsOnlyNumbers(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] < '0' || s[i] > '9' { - return false - } - } - return true -} - -// containsOnlyAlphanumeric checks if the string contains only ASCII letters and numbers. -// -// Example: -// -// fmt.Println(containsOnlyAlphanumeric("abc123")) // Output: true -// fmt.Println(containsOnlyAlphanumeric("abc-123")) // Output: false -func containsOnlyAlphanumeric(s string) bool { - for i := 0; i < len(s); i++ { - c := s[i] - if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) { - return false - } - } - return true -} -// hasLeadingZeroes checks if the string has leading zeroes. -// -// Example: -// -// fmt.Println(hasLeadingZeroes("0123")) // Output: true -// fmt.Println(hasLeadingZeroes("123")) // Output: false -func hasLeadingZeroes(s string) bool { - return len(s) > 1 && s[0] == '0' + return v.partString } diff --git a/range_test.go b/range_test.go index 4e6b4c6..a84648b 100644 --- a/range_test.go +++ b/range_test.go @@ -19,12 +19,30 @@ func TestParseRange(t *testing.T) { input string shouldError bool }{ + // Existing Tests {input: ">1.0.0", shouldError: false}, {input: "<=2.0.0", shouldError: false}, {input: ">=1.2.3 <2.0.0 || >=3.0.0", shouldError: false}, {input: "1.0.0", shouldError: false}, {input: "!=1.0.0", shouldError: false}, {input: "invalid", shouldError: true}, + + // New Tests with Pre-release and Build Metadata + {input: ">1.0.0-alpha", shouldError: false}, // Greater than a pre-release version + {input: "<=2.0.0-beta.1", shouldError: false}, // Less than or equal to a beta pre-release + {input: ">=1.0.0-alpha <2.0.0", shouldError: false}, // Range involving pre-release version + {input: ">=1.2.3+build.123", shouldError: false}, // Version with build metadata + {input: "1.0.0+build.1", shouldError: false}, // Exact match with build metadata + {input: "!=1.0.0-alpha", shouldError: false}, // Not equal to a pre-release version + {input: ">=1.0.0-alpha.1 <1.0.0-alpha.3", shouldError: false}, // Range involving pre-release identifiers + {input: "<1.0.0+build.1", shouldError: false}, // Less than a version with build metadata + {input: "1.0.0-beta+exp.sha.5114f85", shouldError: false}, // Specific version with pre-release and build metadata + {input: ">2.1.0-rc.1 <2.1.0+build.789", shouldError: false}, // Range between a release candidate and a version with build metadata + {input: "1.0.0-alpha+build-metadata", shouldError: false}, // Pre-release version with build metadata + {input: ">1.0.0-invalid", shouldError: false}, // Valid pre-release identifier + {input: "<=1.0.0+build...123", shouldError: true}, // Invalid build metadata + {input: ">=1.2.3-pre-release <3.0.0", shouldError: false}, // Range with complex pre-release identifier + {input: "1.0.0-beta.5+build-xyz", shouldError: false}, // Exact version with pre-release and build metadata } for _, test := range tests { diff --git a/sort_test.go b/sort_test.go index f04585d..7645bce 100644 --- a/sort_test.go +++ b/sort_test.go @@ -17,15 +17,25 @@ func TestSortVersions(t *testing.T) { versionStrings := []string{ "1.0.0", + "1.2.3-alpha+build.123", + "2.0.0-beta.1", + "3.0.0-rc.1", + "1.0.0+build.1", + "1.0.0-alpha.beta", + "2.1.3", + "0.1.0", "1.0.0-alpha", "1.0.0-alpha.1", - "1.0.0-beta.2", - "1.0.0-beta.11", - "1.0.0-rc.1", - "1.0.0", - "2.0.0", - "1.0.1", - "1.1.0", + "2.0.1-beta.2", + "4.0.0-alpha.3+exp.sha.5114f85", + "5.1.0+build.5678", + "3.3.3-rc.2", + "6.2.0-beta+ci.789", + "1.1.1-alpha.2.3", + "7.0.0+build.1234", + "8.0.0-alpha.1.5+meta.data.001", + "2.4.5+build.meta.sha256", + "9.1.2-beta-unstable", } var versions []*Version @@ -37,16 +47,26 @@ func TestSortVersions(t *testing.T) { Sort(versions) expectedOrder := []string{ + "0.1.0", "1.0.0-alpha", "1.0.0-alpha.1", - "1.0.0-beta.2", - "1.0.0-beta.11", - "1.0.0-rc.1", - "1.0.0", + "1.0.0-alpha.beta", "1.0.0", - "1.0.1", - "1.1.0", - "2.0.0", + "1.0.0+build.1", + "1.1.1-alpha.2.3", + "1.2.3-alpha+build.123", + "2.0.0-beta.1", + "2.0.1-beta.2", + "2.1.3", + "2.4.5+build.meta.sha256", + "3.0.0-rc.1", + "3.3.3-rc.2", + "4.0.0-alpha.3+exp.sha.5114f85", + "5.1.0+build.5678", + "6.2.0-beta+ci.789", + "7.0.0+build.1234", + "8.0.0-alpha.1.5+meta.data.001", + "9.1.2-beta-unstable", } for i, v := range versions { @@ -60,11 +80,25 @@ func TestReverseSortVersions(t *testing.T) { versionStrings := []string{ "1.0.0", - "1.0.1", - "1.1.0", - "2.0.0", + "1.2.3-alpha+build.123", + "2.0.0-beta.1", + "3.0.0-rc.1", + "1.0.0+build.1", + "1.0.0-alpha.beta", + "2.1.3", + "0.1.0", "1.0.0-alpha", - "1.0.0-beta", + "1.0.0-alpha.1", + "2.0.1-beta.2", + "4.0.0-alpha.3+exp.sha.5114f85", + "5.1.0+build.5678", + "3.3.3-rc.2", + "6.2.0-beta+ci.789", + "1.1.1-alpha.2.3", + "7.0.0+build.1234", + "8.0.0-alpha.1.5+meta.data.001", + "2.4.5+build.meta.sha256", + "9.1.2-beta-unstable", } var versions []*Version @@ -77,12 +111,26 @@ func TestReverseSortVersions(t *testing.T) { Reverse(versions) expectedOrder := []string{ - "2.0.0", - "1.1.0", - "1.0.1", + "9.1.2-beta-unstable", + "8.0.0-alpha.1.5+meta.data.001", + "7.0.0+build.1234", + "6.2.0-beta+ci.789", + "5.1.0+build.5678", + "4.0.0-alpha.3+exp.sha.5114f85", + "3.3.3-rc.2", + "3.0.0-rc.1", + "2.4.5+build.meta.sha256", + "2.1.3", + "2.0.1-beta.2", + "2.0.0-beta.1", + "1.2.3-alpha+build.123", + "1.1.1-alpha.2.3", + "1.0.0+build.1", "1.0.0", - "1.0.0-beta", + "1.0.0-alpha.beta", + "1.0.0-alpha.1", "1.0.0-alpha", + "0.1.0", } for i, v := range versions { diff --git a/version.go b/version.go index ad23001..e6bd18b 100644 --- a/version.go +++ b/version.go @@ -6,29 +6,51 @@ package semver import ( - "database/sql/driver" - "encoding/json" "errors" "fmt" "strconv" "strings" ) -// Error messages for version parsing var ( - ErrEmptyVersionString = errors.New("version string empty") - ErrInvalidMajorNumber = "invalid character(s) found in major number %q" - ErrLeadingZeroMajor = "major number must not contain leading zeroes %q" - ErrInvalidMinorNumber = "invalid character(s) found in minor number %q" - ErrLeadingZeroMinor = "minor number must not contain leading zeroes %q" - ErrMissingVersionElements = errors.New("missing major, minor, or patch elements") - ErrInvalidPatchNumber = "invalid character(s) found in patch number %q" - ErrLeadingZeroPatch = "patch number must not contain leading zeroes %q" - ErrEmptyBuildMetadata = errors.New("build metadata is empty") - ErrInvalidBuildMetadataChars = "invalid character(s) found in build metadata %q" + // ErrEmptyVersionString indicates that the version string provided is empty. + ErrEmptyVersionString = errors.New("version string is empty") + + // ErrMissingVersionElements indicates that one or more of the major, minor, or patch elements are missing in the version string. + ErrMissingVersionElements = errors.New("missing major, minor, or patch elements") + + // ErrInvalidNumericIdentifier indicates that a numeric identifier (e.g., major, minor, or patch) is not a valid number. + ErrInvalidNumericIdentifier = errors.New("invalid numeric identifier") + + // ErrLeadingZeroInNumericIdentifier indicates that a numeric identifier has a leading zero, which is not allowed. + ErrLeadingZeroInNumericIdentifier = errors.New("leading zeros are not allowed in numeric identifiers") + + // ErrInvalidCharacterInIdentifier indicates that an identifier contains an invalid character. + ErrInvalidCharacterInIdentifier = errors.New("invalid character in identifier") + + // ErrInvalidPrereleaseIdentifier indicates that a pre-release identifier contains invalid characters or is malformed. + ErrInvalidPrereleaseIdentifier = errors.New("invalid pre-release identifier") + + // ErrEmptyPrereleaseIdentifier indicates that a pre-release identifier is empty, which is not allowed. + ErrEmptyPrereleaseIdentifier = errors.New("empty pre-release identifier") + + // ErrEmptyBuildMetadata indicates that the build metadata portion of the version string is empty. + ErrEmptyBuildMetadata = errors.New("build metadata is empty") + + // ErrInvalidBuildMetadataIdentifier indicates that the build metadata contains invalid characters or is malformed. + ErrInvalidBuildMetadataIdentifier = errors.New("invalid build metadata identifier") + + // ErrUnexpectedCharacter indicates that an unexpected character was encountered in the version string. + ErrUnexpectedCharacter = errors.New("unexpected character in version string") + + // ErrUnexpectedEndOfInput indicates that the version string ended unexpectedly during parsing. + ErrUnexpectedEndOfInput = errors.New("unexpected end of input while parsing version string") + + // ErrUnsupportedType indicates that an unsupported type was provided for Version. + ErrUnsupportedType = errors.New("unsupported type for Version") ) -// SupportedVersion is the latest fully supported specification version of semver. +// SupportedVersion is the latest fully supported Semantic Versioning specification version. // // Example: // @@ -61,7 +83,120 @@ type Version struct { Patch uint64 } -// NewVersion creates a new Version instance with the specified major, minor, patch components, +var ( + // DefaultParser is a global, shared instance of a parser. It is safe for concurrent use. + DefaultParser Parser +) + +func init() { + var err error + DefaultParser, err = NewParser() + if err != nil { + panic(fmt.Sprintf("failed to initialize DefaultParser: %v", err)) + } +} + +// NewParser creates a new Parser instance with the provided options. +// This function accepts a variadic number of Option parameters, allowing +// users to configure the behavior of the Parser as needed. +// +// Options can be used to customize various aspects of the Parser, such as +// specifying custom delimiters, enabling or disabling specific parsing features, +// or configuring error handling behavior. +// +// Example usage: +// +// // Create a Parser with default settings +// parser, err := NewParser() +// if err != nil { +// log.Fatalf("Failed to create parser: %v", err) +// } +// +// // Create a Parser with custom options +// parser, err := NewParser(WithDelimiter(','), WithStrictAdherence(true)) +// if err != nil { +// log.Fatalf("Failed to create custom parser: %v", err) +// } +// +// Parameters: +// - options: A variadic list of Option functions used to configure the Parser. +// +// Returns: +// - Parser: An instance of the Parser configured with the specified options. +// - error: An error if there is an issue creating the Parser, otherwise nil. +func NewParser(options ...Option) (Parser, error) { + // Initialize ConfigOptions with default values. + // These defaults include the default alphabet, the default random reader, + // and the default length hint for ID generation. + configOpts := &ConfigOptions{ + Strict: true, + } + + // Apply provided options to customize the configuration. + // Each Option function modifies the ConfigOptions accordingly. + for _, opt := range options { + opt(configOpts) + } + + config, err := buildRuntimeConfig(configOpts) + if err != nil { + return nil, err + } + + return &parser{ + config: config, + }, nil +} + +// Parser defines an interface for parsing version strings into structured Version objects. +// Implementations of this interface are responsible for validating and converting a version +// string into a Version type that can be used programmatically. +// +// This interface can be useful when dealing with different version formats or when you need +// to standardize version parsing across multiple components of an application. +// +// Example usage: +// +// var parser Parser = NewParser() +// versionStr := "1.2.3-beta+build.123" +// version, err := parser.Parse(versionStr) +// if err != nil { +// log.Fatalf("Failed to parse version: %v", err) +// } +// fmt.Printf("Parsed version: %v\n", version) +// +// Methods: +// - Parse(version string) (Version, error): Parses a version string and returns a Version object. +// Returns an error if the version string is invalid or cannot be parsed. +type Parser interface { + // Parse takes a version string as input and converts it into a structured Version object. + // The input version string must follow a valid versioning format, and the implementation + // of the method is responsible for handling the parsing logic. + // + // If the provided version string is invalid or cannot be parsed, an error will be returned. + // + // Parameters: + // - version: A string representing the version to be parsed (e.g., "1.2.3", "1.0.0-alpha+build.123"). + // + // Returns: + // - Version: A Version object representing the parsed version information. + // - error: An error if the version string is invalid or cannot be parsed. + // + // Example usage: + // + // version, err := parser.Parse("1.2.3") + // if err != nil { + // log.Fatalf("Failed to parse version: %v", err) + // } + // fmt.Printf("Parsed version: %v\n", version) + Parse(version string) (Version, error) +} + +type parser struct { + config *runtimeConfig +} + +// New creates a new Version instance with the specified major, minor, patch components, // optional prerelease identifiers, and optional build metadata. // // The prerelease identifiers determine the precedence of the version relative to other versions with the same @@ -87,17 +222,14 @@ type Version struct { // ) // // func main() { -// preRelease := []semver.PrereleaseVersion{ -// {partString: "alpha", isNumeric: false}, -// {partNumeric: 1, isNumeric: true}, -// } +// preRelease := NewPrereleaseVersion("alpha.1") // buildMetadata := []string{"build", "2024"} // -// v := semver.NewVersion(1, 2, 3, preRelease, buildMetadata) +// v := semver.New(1, 2, 3, preRelease, buildMetadata) // // fmt.Println(v.String()) // Output: 1.2.3-alpha.1+build.2024 // } -func NewVersion(major, minor, patch uint64, preRelease []PrereleaseVersion, buildMetadata []string) Version { +func New(major, minor, patch uint64, preRelease []PrereleaseVersion, buildMetadata []string) Version { return Version{ Major: major, Minor: minor, @@ -107,6 +239,20 @@ func NewVersion(major, minor, patch uint64, preRelease []PrereleaseVersion, buil } } +// MustParse is a helper function that parses a version string and panics if invalid. +// +// Example: +// +// v := semver.MustParse("1.2.3") +// fmt.Println(v) // Output: 1.2.3 +func MustParse(version string) Version { + v, err := DefaultParser.Parse(version) + if err != nil { + panic(err) + } + return v +} + // Parse parses a version string into a Version struct. // // Returns an error if the version string is not a valid semantic version. @@ -119,119 +265,302 @@ func NewVersion(major, minor, patch uint64, preRelease []PrereleaseVersion, buil // } else { // fmt.Println(v) // Output: 1.2.3-alpha.1+build.123 // } +// +// Parse parses a version string into a Version struct. +// The version string must follow semantic versioning format, such as "1.0.0-alpha+001". +// It returns an error if the version string is invalid. func Parse(version string) (Version, error) { + return DefaultParser.Parse(version) +} + +// Parse parses a version string into a Version struct. +// +// Returns an error if the version string is not a valid semantic version. +// +// Example: +// +// v, err := semver.Parse("1.2.3-alpha.1+build.123") +// if err != nil { +// fmt.Println("Error:", err) +// } else { +// fmt.Println(v) // Output: 1.2.3-alpha.1+build.123 +// } +// +// Parse parses a version string into a Version struct. +// The version string must follow semantic versioning format, such as "1.0.0-alpha+001". +// It returns an error if the version string is invalid. +func (p *parser) Parse(version string) (Version, error) { if len(version) == 0 { return Version{}, ErrEmptyVersionString } - v := Version{} - var start, dotCount int - - // Parse Major, Minor, and Patch components by identifying '.' separators. - for i := 0; i < len(version); i++ { - if version[i] == '.' { - switch dotCount { - case 0: // Major - if !containsOnlyNumbers(version[start:i]) { - return Version{}, fmt.Errorf(ErrInvalidMajorNumber, version[start:i]) - } - if hasLeadingZeroes(version[start:i]) { - return Version{}, fmt.Errorf(ErrLeadingZeroMajor, version[start:i]) - } - major, err := strconv.ParseUint(version[start:i], 10, 64) - if err != nil { - return Version{}, err - } - v.Major = major - start = i + 1 - case 1: // Minor - if !containsOnlyNumbers(version[start:i]) { - return Version{}, fmt.Errorf(ErrInvalidMinorNumber, version[start:i]) - } - if hasLeadingZeroes(version[start:i]) { - return Version{}, fmt.Errorf(ErrLeadingZeroMinor, version[start:i]) - } - minor, err := strconv.ParseUint(version[start:i], 10, 64) - if err != nil { - return Version{}, err - } - v.Minor = minor - start = i + 1 - } - dotCount++ - } else if version[i] == '-' || version[i] == '+' { - break - } + var v Version + var index int + length := len(version) + var err error + + // Parse Major + v.Major, index, err = p.parseNumericIdentifier(version, index, length) + if err != nil { + return Version{}, err } - if dotCount != 2 { + // Expect a '.' after Major + if index >= length || version[index] != '.' { return Version{}, ErrMissingVersionElements } + index++ // Skip '.' - // Parse Patch - i := start - for i < len(version) && version[i] != '-' && version[i] != '+' { - i++ - } - patchStr := version[start:i] - if !containsOnlyNumbers(patchStr) { - return Version{}, fmt.Errorf(ErrInvalidPatchNumber, patchStr) + // Parse Minor + v.Minor, index, err = p.parseNumericIdentifier(version, index, length) + if err != nil { + return Version{}, err } - if hasLeadingZeroes(patchStr) { - return Version{}, fmt.Errorf(ErrLeadingZeroPatch, patchStr) + + // Expect a '.' after Minor + if index >= length || version[index] != '.' { + return Version{}, ErrMissingVersionElements } - patch, err := strconv.ParseUint(patchStr, 10, 64) + index++ // Skip '.' + + // Parse Patch + v.Patch, index, err = p.parseNumericIdentifier(version, index, length) if err != nil { return Version{}, err } - v.Patch = patch - // Parse Prerelease and Build Metadata - for i < len(version) { - if version[i] == '-' { - start = i + 1 - i++ - for i < len(version) && version[i] != '+' { - i++ + // Parse PreRelease and BuildMetadata if any + if index < length { + index, err = p.parsePreReleaseAndBuildMetadata(version, index, length, &v) + if err != nil { + return Version{}, err + } + } + + if index != length { + return Version{}, ErrUnexpectedCharacter + } + + return v, nil +} + +// parseNumericIdentifier parses a numeric identifier from the version string. +// It returns the parsed value, the updated index, or an error if the parsing fails. +func (p *parser) parseNumericIdentifier(version string, index int, length int) (uint64, int, error) { + if index >= length { + return 0, index, ErrUnexpectedEndOfInput + } + + start := index + if version[index] == '0' { + index++ + if index < length && version[index] >= '0' && version[index] <= '9' { + return 0, index, ErrLeadingZeroInNumericIdentifier + } + return 0, index, nil + } + + var n uint64 + for index < length && version[index] >= '0' && version[index] <= '9' { + n = n*10 + uint64(version[index]-'0') + index++ + } + + if start == index { + return 0, index, ErrInvalidNumericIdentifier + } + + return n, index, nil +} + +// parsePreReleaseAndBuildMetadata parses the pre-release and build metadata components from the version string. +// It updates the Version struct with the parsed values and returns the updated index or an error. +func (p *parser) parsePreReleaseAndBuildMetadata(version string, index int, length int, v *Version) (int, error) { + var err error + + // Parse PreRelease if present + if index < length && version[index] == '-' { + index++ // Skip '-' + start := index + for index < length && version[index] != '+' { + if version[index] > 127 { + return index, ErrInvalidCharacterInIdentifier } - prereleaseStr := version[start:i] - - // Split prerelease by '.' - parts := strings.Split(prereleaseStr, ".") - v.PreRelease = make([]PrereleaseVersion, 0, len(parts)) - for _, part := range parts { - parsedPR, err := NewPrereleaseVersion(part) - if err != nil { - return Version{}, err - } - v.PreRelease = append(v.PreRelease, parsedPR) + index++ + } + prerelease := version[start:index] + v.PreRelease, err = p.parsePrerelease(prerelease) + if err != nil { + return index, err + } + } + + // Parse BuildMetadata if present + if index < length && version[index] == '+' { + index++ // Skip '+' + start := index + build := version[start:] + v.BuildMetadata, err = p.parseBuildMetadata(build) + if err != nil { + return index, err + } + index = length // End of string + } + + return index, nil +} + +// parsePrerelease parses the given string into a slice of PrereleaseVersion components. +// The string is expected to contain prerelease identifiers separated by dots. +// +// Prerelease identifiers must conform to the following rules: +// - Identifiers must not be empty. +// - Identifiers must only contain alphanumeric characters or hyphens. +// - Numeric identifiers must not have leading zeros. +// +// Returns an error if the input string is empty, contains invalid characters, or contains empty identifiers. +// +// Example: +// +// s := "alpha.1.0-beta" +// prerelease, err := parsePrerelease(s) +// if err != nil { +// // handle error +// } +func (p *parser) parsePrerelease(s string) ([]PrereleaseVersion, error) { + if len(s) == 0 { + return nil, ErrEmptyPrereleaseIdentifier + } + + var prerelease []PrereleaseVersion + length := len(s) + start := 0 + + for i := 0; i <= length; i++ { + if i == length || s[i] == '.' { + if start == i { + return nil, ErrEmptyPrereleaseIdentifier + } + part := s[start:i] + + if !p.isValidPrereleaseIdentifier(part) { + return nil, ErrInvalidPrereleaseIdentifier } - } else if version[i] == '+' { + component, err := NewPrereleaseVersion(part) + + if err != nil { + return nil, err + } + + prerelease = append(prerelease, component) start = i + 1 - i++ - for i < len(version) { - i++ + } else if s[i] > 127 || !p.isAllowedInIdentifier(s[i]) { + return nil, ErrInvalidCharacterInIdentifier + } + } + return prerelease, nil +} + +// parseBuildMetadata parses the given string into a slice of build metadata components. +// The string is expected to contain build identifiers separated by dots. +// +// Build identifiers must conform to the following rules: +// - Identifiers must not be empty. +// - Identifiers must only contain alphanumeric characters or hyphens. +// +// Returns an error if the input string is empty, contains invalid characters, or contains empty identifiers. +// +// Example: +// +// s := "001.alpha" +// buildMetadata, err := parseBuildMetadata(s) +// if err != nil { +// // handle error +// } +func (p *parser) parseBuildMetadata(s string) ([]string, error) { + if len(s) == 0 { + return nil, ErrEmptyBuildMetadata + } + + var buildMetadata []string + length := len(s) + start := 0 + + for i := 0; i <= length; i++ { + if i == length || s[i] == '.' { + if start == i { + return nil, ErrEmptyBuildMetadata } - buildStr := version[start:i] - - // Split build metadata by '.' - parts := strings.Split(buildStr, ".") - v.BuildMetadata = make([]string, 0, len(parts)) - for _, part := range parts { - if len(part) == 0 { - return Version{}, ErrEmptyBuildMetadata - } - if !containsOnlyAlphanumeric(part) { - return Version{}, fmt.Errorf(ErrInvalidBuildMetadataChars, part) - } - v.BuildMetadata = append(v.BuildMetadata, part) + part := s[start:i] + if !p.isValidBuildIdentifier(part) { + return nil, ErrInvalidBuildMetadataIdentifier } - } else { - i++ + buildMetadata = append(buildMetadata, part) + start = i + 1 + } else if s[i] > 127 || !p.isAllowedInIdentifier(s[i]) { + return nil, ErrInvalidCharacterInIdentifier } } + return buildMetadata, nil +} - return v, nil +// isAllowedInIdentifier checks if a character is allowed in a semantic version identifier. +// Allowed characters are: +// - Digits ('0'-'9') +// - Uppercase letters ('A'-'Z') +// - Lowercase letters ('a'-'z') +// - Hyphen ('-') +func (p *parser) isAllowedInIdentifier(ch byte) bool { + return (ch >= '0' && ch <= '9') || + (ch >= 'A' && ch <= 'Z') || + (ch >= 'a' && ch <= 'z') || + ch == '-' +} + +// isValidPrereleaseIdentifier checks if a prerelease identifier is valid. +// The identifier must not be empty and must contain only allowed characters. +// Numeric identifiers must not have leading zeros. +func (p *parser) isValidPrereleaseIdentifier(s string) bool { + if len(s) == 0 { + return false + } + for i := 0; i < len(s); i++ { + ch := s[i] + if !p.isAllowedInIdentifier(ch) { + return false + } + } + if p.config.StrictAdherence() && isNumeric(s) && s[0] == '0' && len(s) > 1 { + return false // Leading zeros are not allowed in numeric identifiers + } + + return true +} + +// isValidBuildIdentifier checks if a build metadata identifier is valid. +// The identifier must not be empty and must contain only allowed characters. +func (p *parser) isValidBuildIdentifier(s string) bool { + if len(s) == 0 { + return false + } + for i := 0; i < len(s); i++ { + ch := s[i] + if !p.isAllowedInIdentifier(ch) { + return false + } + } + return true +} + +// isNumeric checks if a string consists only of numeric characters ('0'-'9'). +func isNumeric(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + + return true } // String returns the string representation of the Version. @@ -316,15 +645,15 @@ func (v Version) Compare(other Version) int { } // Compare pre-release identifiers one by one - minLen := len(v.PreRelease) - if len(other.PreRelease) < minLen { - minLen = len(other.PreRelease) + length := len(v.PreRelease) + if len(other.PreRelease) < length { + length = len(other.PreRelease) } - for i := 0; i < minLen; i++ { - comp := v.PreRelease[i].Compare(other.PreRelease[i]) - if comp != 0 { - return comp + for i := 0; i < length; i++ { + c := v.PreRelease[i].Compare(other.PreRelease[i]) + if c != 0 { + return c } } @@ -335,6 +664,7 @@ func (v Version) Compare(other Version) int { if len(v.PreRelease) > len(other.PreRelease) { return 1 } + return 0 } @@ -393,152 +723,10 @@ func (v Version) GreaterThanOrEqual(other Version) bool { return v.Compare(other) >= 0 } -// MustParse is a helper function that parses a version string and panics if invalid. -// -// Example: -// -// v := semver.MustParse("1.2.3") -// fmt.Println(v) // Output: 1.2.3 -func MustParse(version string) Version { - v, err := Parse(version) - if err != nil { - panic(err) - } - return v -} - -// MarshalText implements encoding.TextMarshaler. -// It returns the string representation of the Version. -// -// Example: -// -// v := semver.MustParse("1.2.3-alpha+build.456") -// text, err := v.MarshalText() -// if err != nil { -// log.Fatal(err) -// } -// fmt.Println(string(text)) // Output: 1.2.3-alpha+build.456 -func (v Version) MarshalText() ([]byte, error) { - return []byte(v.String()), nil -} - -// UnmarshalText implements encoding.TextUnmarshaler. -// It parses the given text into a Version. -// -// Example: -// -// var v semver.Version -// err := v.UnmarshalText([]byte("1.2.3-alpha+build.456")) -// if err != nil { -// log.Fatal(err) -// } -// fmt.Println(v) // Output: 1.2.3-alpha+build.456 -func (v *Version) UnmarshalText(text []byte) error { - parsed, err := Parse(string(text)) - if err != nil { - return err - } - *v = parsed - return nil -} - -// MarshalBinary implements encoding.BinaryMarshaler. -// It returns the binary encoding of the Version. -// -// Example: -// -// v := semver.MustParse("1.2.3-alpha") -// binaryData, err := v.MarshalBinary() -// if err != nil { -// log.Fatal(err) -// } -// fmt.Printf("%s\n", binaryData) // Output: 1.2.3-alpha -func (v Version) MarshalBinary() ([]byte, error) { - return v.MarshalText() -} - -// UnmarshalBinary implements encoding.BinaryUnmarshaler. -// It decodes the given binary data into a Version. -// -// Example: -// -// var v semver.Version -// err := v.UnmarshalBinary([]byte("1.2.3+build.123")) -// if err != nil { -// log.Fatal(err) -// } -// fmt.Println(v) // Output: 1.2.3+build.123 -func (v *Version) UnmarshalBinary(data []byte) error { - return v.UnmarshalText(data) -} - -// MarshalJSON implements json.Marshaler. -// It returns the JSON encoding of the Version. -// -// Example: -// -// v := semver.MustParse("1.2.3-beta") -// jsonData, err := v.MarshalJSON() -// if err != nil { -// log.Fatal(err) -// } -// fmt.Println(string(jsonData)) // Output: "1.2.3-beta" -func (v Version) MarshalJSON() ([]byte, error) { - return json.Marshal(v.String()) -} - -// UnmarshalJSON implements json.Unmarshaler. -// It decodes JSON data into a Version. -// -// Example: -// -// var v semver.Version -// err := v.UnmarshalJSON([]byte("\"1.2.3-beta+build\"")) -// if err != nil { -// log.Fatal(err) -// } -// fmt.Println(v) // Output: 1.2.3-beta+build -func (v *Version) UnmarshalJSON(data []byte) error { - var text string - if err := json.Unmarshal(data, &text); err != nil { - return err - } - return v.UnmarshalText([]byte(text)) -} - -// Value implements database/sql/driver.Valuer. -// It returns the string representation of the Version as a database value. -// -// Example: +// Config holds the runtime configuration for the Nano ID generator. // -// v := semver.MustParse("1.2.3-alpha") -// dbValue, err := v.Value() -// if err != nil { -// log.Fatal(err) -// } -// fmt.Println(dbValue) // Output: 1.2.3-alpha -func (v Version) Value() (driver.Value, error) { - return v.String(), nil -} - -// Scan implements database/sql.Scanner. -// It scans a database value into a Version. -// -// Example: -// -// var v semver.Version -// err := v.Scan("1.2.3-alpha+build") -// if err != nil { -// log.Fatal(err) -// } -// fmt.Println(v) // Output: 1.2.3-alpha+build -func (v *Version) Scan(value interface{}) error { - switch t := value.(type) { - case string: - return v.UnmarshalText([]byte(t)) - case []byte: - return v.UnmarshalText(t) - default: - return fmt.Errorf("unsupported type %T for Version", value) - } +// It is immutable after initialization and provides all the necessary +// parameters for generating unique IDs efficiently and securely. +func (p *parser) Config() Config { + return p.config } diff --git a/version_benchmark_test.go b/version_benchmark_test.go index 1baceda..31b46ae 100644 --- a/version_benchmark_test.go +++ b/version_benchmark_test.go @@ -10,127 +10,87 @@ import ( ) func BenchmarkParseVersionSerial(b *testing.B) { - versions := []string{ - "1.0.0", - "1.2.3-alpha+build.123", - "2.0.0-beta.1", - "3.0.0-rc.1", - "1.0.0+build.1", - "1.0.0-alpha.beta", - "2.1.3", - "0.1.0", - "1.0.0-alpha", - "1.0.0-alpha.1", + b.ReportAllocs() + + p, err := NewParser(WithStrictAdherence(true)) + if err != nil { + b.Fatalf("Error creating parser: %v", err) } - // Preallocate slice to avoid allocation during benchmarking - parsers := make([]Version, len(versions)) + versions := []string{ + "2.0.1-beta.2", + "4.0.0-alpha.3+exp.sha.5114f85", + "5.1.0+build.5678", + "3.3.3-rc.2", + "6.2.0-beta+ci.789", + "1.1.1-alpha.2.3", + "7.0.0+build.1234", + "8.0.0-alpha.1.5+meta.data.001", + "2.4.5+build.meta.sha256", + "9.1.2-beta-unstable", + } b.ResetTimer() for i := 0; i < b.N; i++ { - for j, v := range versions { - var err error - parsers[j], err = Parse(v) - if err != nil { - b.Errorf("Error parsing version %s: %v", v, err) - } + // Use modulo to select a different version in each loop + version := versions[i%len(versions)] + _, err = p.Parse(version) + if err != nil { + b.Errorf("Error parsing version %s: %v", version, err) } } } func BenchmarkParseVersionConcurrent(b *testing.B) { + b.ReportAllocs() + + p, err := NewParser(WithStrictAdherence(true)) + if err != nil { + b.Fatalf("Error creating parser: %v", err) + } + versions := []string{ - "1.0.0", - "1.2.3-alpha+build.123", - "2.0.0-beta.1", - "3.0.0-rc.1", - "1.0.0+build.1", - "1.0.0-alpha.beta", - "2.1.3", - "0.1.0", - "1.0.0-alpha", - "1.0.0-alpha.1", + "2.0.1-beta.2", + "4.0.0-alpha.3+exp.sha.5114f85", + "5.1.0+build.5678", + "3.3.3-rc.2", + "6.2.0-beta+ci.789", + "1.1.1-alpha.2.3", + "7.0.0+build.1234", + "8.0.0-alpha.1.5+meta.data.001", + "2.4.5+build.meta.sha256", + "9.1.2-beta-unstable", } b.ResetTimer() b.RunParallel(func(pb *testing.PB) { + i := 0 for pb.Next() { - for _, v := range versions { - _, err := Parse(v) - if err != nil { - b.Errorf("Error parsing version %s: %v", v, err) - } + // Use modulo to select a different version in each loop + version := versions[i%len(versions)] + _, err := p.Parse(version) + if err != nil { + b.Errorf("Error parsing version %s: %v", version, err) } + i++ } }) } func BenchmarkParseVersionAllocations(b *testing.B) { - version := "1.2.3-alpha.1+build.123" - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := Parse(version) - if err != nil { - b.Errorf("Error parsing version %s: %v", version, err) - } - } -} + version := "1.2.3-alpha.1+build.123" -func BenchmarkParseVersionLargeSerial(b *testing.B) { - // Generate a large number of versions - var versions []string - baseVersions := []string{ - "1.0.0", - "1.2.3-alpha+build.123", - "2.0.0-beta.1", - "3.0.0-rc.1", - "1.0.0+build.1", - "1.0.0-alpha.beta", + p, err := NewParser(WithStrictAdherence(true)) + if err != nil { + b.Errorf("Error creating parser: %v", err) } - for i := 0; i < 10000; i++ { - versions = append(versions, baseVersions...) - } - - parsers := make([]Version, len(versions)) b.ResetTimer() for i := 0; i < b.N; i++ { - for j, v := range versions { - var err error - parsers[j], err = Parse(v) - if err != nil { - b.Errorf("Error parsing version %s: %v", v, err) - } + _, err := p.Parse(version) + if err != nil { + b.Errorf("Error parsing version %s: %v", version, err) } } } - -func BenchmarkParseVersionLargeConcurrent(b *testing.B) { - // Generate a large number of versions - var versions []string - baseVersions := []string{ - "1.0.0", - "1.2.3-alpha+build.123", - "2.0.0-beta.1", - "3.0.0-rc.1", - "1.0.0+build.1", - "1.0.0-alpha.beta", - } - for i := 0; i < 10000; i++ { - versions = append(versions, baseVersions...) - } - - b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - for _, v := range versions { - _, err := Parse(v) - if err != nil { - b.Errorf("Error parsing version %s: %v", v, err) - } - } - } - }) -} diff --git a/version_test.go b/version_test.go index aa9587c..e51e5e4 100644 --- a/version_test.go +++ b/version_test.go @@ -6,7 +6,7 @@ package semver import ( - "encoding/json" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -67,7 +67,7 @@ func TestNewVersion(t *testing.T) { // Execute each test case for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - v := NewVersion(tc.major, tc.minor, tc.patch, tc.preRelease, tc.buildMetadata) + v := New(tc.major, tc.minor, tc.patch, tc.preRelease, tc.buildMetadata) is.Equal(tc.expected, v.String(), "Version string should match expected value") }) } @@ -182,6 +182,7 @@ func TestVersionComparison(t *testing.T) { {"1.0.0-alpha", "1.0.0-beta", -1}, {"1.0.0-alpha.1", "1.0.0-alpha", 1}, {"1.0.0+build1", "1.0.0+build2", 0}, // Build metadata is ignored in comparison + {"1.0.0+build1", "1.0.0", 0}, // Build metadata is ignored in comparison } for _, test := range tests { @@ -259,93 +260,75 @@ func TestVersionPreReleaseComparison(t *testing.T) { } } -func TestVersionMarshalText(t *testing.T) { - t.Parallel() - v := MustParse("1.2.3-alpha+build.123") - text, err := v.MarshalText() - - is := assert.New(t) - is.NoError(err) - is.Equal("1.2.3-alpha+build.123", string(text)) -} - -func TestVersionUnmarshalText(t *testing.T) { - t.Parallel() - var v Version - err := v.UnmarshalText([]byte("1.2.3-alpha+build.123")) - - is := assert.New(t) - is.NoError(err) - is.Equal(MustParse("1.2.3-alpha+build.123"), v) -} - -func TestVersionMarshalBinary(t *testing.T) { - t.Parallel() - v := MustParse("1.2.3-beta") - binaryData, err := v.MarshalBinary() - - is := assert.New(t) - is.NoError(err) - is.Equal([]byte("1.2.3-beta"), binaryData) -} - -func TestVersionUnmarshalBinary(t *testing.T) { - t.Parallel() - var v Version - err := v.UnmarshalBinary([]byte("1.2.3+build.456")) - - is := assert.New(t) - is.NoError(err) - is.Equal(MustParse("1.2.3+build.456"), v) -} - -func TestVersionMarshalJSON(t *testing.T) { - t.Parallel() - v := MustParse("1.2.3-alpha") - jsonData, err := json.Marshal(v) - - is := assert.New(t) - is.NoError(err) - is.JSONEq(`"1.2.3-alpha"`, string(jsonData)) -} - -func TestVersionUnmarshalJSON(t *testing.T) { - t.Parallel() - var v Version - err := json.Unmarshal([]byte(`"1.2.3-beta+build.789"`), &v) - - is := assert.New(t) - is.NoError(err) - is.Equal(MustParse("1.2.3-beta+build.789"), v) -} - -func TestVersionValue(t *testing.T) { - t.Parallel() - v := MustParse("1.2.3-alpha") - dbValue, err := v.Value() +func TestParseInvalidVersions(t *testing.T) { + // Define a list of invalid versions to test. + invalidVersions := []string{ + "1.0", // Incomplete version (missing patch version) + "v1.0.0", // Prefix with `v` is not allowed in Semver + "1.0.0-alpha..1", // Double dots are invalid + "1.0.0+build+123", // Invalid multiple `+` in build metadata + "1.0.0-01", // Leading zeros in numeric pre-release identifiers are not allowed + "1.0.0-", // Ends with a dash + "1.0.0+build.!", // Invalid character `!` in build metadata + "1.0.0-beta_$", // Invalid character `$` in pre-release identifier + "1..0.0", // Double dots in the version components + } - is := assert.New(t) - is.NoError(err) - is.Equal("1.2.3-alpha", dbValue) + for _, version := range invalidVersions { + t.Run(version, func(t *testing.T) { + _, err := Parse(version) + if err == nil { + t.Errorf("expected error for invalid version: %s, but got none", version) + } + }) + } } -func TestVersionScan(t *testing.T) { - t.Parallel() - var v Version - is := assert.New(t) +func TestStrictAdherence(t *testing.T) { + strictParser, err := NewParser(WithStrictAdherence(true)) + if err != nil { + t.Fatalf("Failed to create strict parser: %v", err) + } - // Test with string - err := v.Scan("1.2.3-alpha+build.123") - is.NoError(err) - is.Equal(MustParse("1.2.3-alpha+build.123"), v) + nonStrictParser, err := NewParser(WithStrictAdherence(false)) + if err != nil { + t.Fatalf("Failed to create non-strict parser: %v", err) + } - // Test with []byte - err = v.Scan([]byte("1.2.3-beta")) - is.NoError(err) - is.Equal(MustParse("1.2.3-beta"), v) + // Test cases for strict adherence + tests := []struct { + parser Parser + input string + expectError bool + }{ + // Strict adherence enabled - should fail for leading zeros + {parser: strictParser, input: "1.01.0", expectError: true}, + {parser: strictParser, input: "1.0.00", expectError: true}, + {parser: strictParser, input: "01.0.0", expectError: true}, + + // Strict adherence enabled - should succeed for valid versions + {parser: strictParser, input: "1.0.0", expectError: false}, + {parser: strictParser, input: "1.2.3-alpha.1", expectError: false}, + + // Non-strict adherence - should allow leading zeros + {parser: nonStrictParser, input: "1.01.0", expectError: false}, + {parser: nonStrictParser, input: "1.0.00", expectError: false}, + {parser: nonStrictParser, input: "01.0.0", expectError: false}, + + // Non-strict adherence - should succeed for valid versions + {parser: nonStrictParser, input: "1.0.0", expectError: false}, + {parser: nonStrictParser, input: "1.2.3-alpha.1", expectError: false}, + } - // Test with unsupported type - err = v.Scan(123) - is.Error(err) - is.EqualError(err, "unsupported type int for Version") + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + _, err := tt.parser.Parse(tt.input) + if tt.parser == nonStrictParser && errors.Is(err, ErrLeadingZeroInNumericIdentifier) { + err = nil // Allow leading zeros in non-strict mode + } + if (err != nil) != tt.expectError { + t.Errorf("Parse(%s) strict=%v: expected error: %v, got: %v", tt.input, tt.parser == strictParser, tt.expectError, err) + } + }) + } }