From 53977182ba2e13eda9f8d2b16851581d2a9a44b6 Mon Sep 17 00:00:00 2001 From: Michael Primeaux Date: Mon, 25 Nov 2024 17:13:32 -0600 Subject: [PATCH] feature: parsing --- CHANGELOG.md | 7 ++ README.md | 73 +++++++------ config.go | 111 ++++++++++++++++++++ config_test.go | 29 ++++++ marshalers_test.go | 104 +++++++++++++++++++ range_test.go | 18 ++++ sort_test.go | 92 +++++++++++++---- version.go | 209 +++++++++++++++++++++++++++++++------- version_benchmark_test.go | 146 ++++++++++---------------- version_test.go | 151 ++++++++++++--------------- 10 files changed, 667 insertions(+), 273 deletions(-) create mode 100644 config.go create mode 100644 config_test.go create mode 100644 marshalers_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bba60d..66acba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,18 @@ Date format: `YYYY-MM-DD` ## [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 --- diff --git a/README.md b/README.md index 06d5eb1..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 1642676 725.2 ns/op 544 B/op 14 allocs/op -BenchmarkParseVersionConcurrent-24 4059534 303.9 ns/op 544 B/op 14 allocs/op -BenchmarkParseVersionAllocations-24 7236553 162.4 ns/op 144 B/op 4 allocs/op -BenchmarkParseVersionLargeSerial-24 208 5747548 ns/op 4160079 B/op 110000 allocs/op -BenchmarkParseVersionLargeConcurrent-24 656 1874606 ns/op 4160163 B/op 110000 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.274s +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_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/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 371d38b..e6bd18b 100644 --- a/version.go +++ b/version.go @@ -7,6 +7,7 @@ package semver import ( "errors" + "fmt" "strconv" "strings" ) @@ -82,6 +83,119 @@ type Version struct { Patch uint64 } +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. // @@ -108,10 +222,7 @@ 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.New(1, 2, 3, preRelease, buildMetadata) @@ -128,6 +239,20 @@ func New(major, minor, patch uint64, preRelease []PrereleaseVersion, buildMetada } } +// 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. @@ -145,6 +270,26 @@ func New(major, minor, patch uint64, preRelease []PrereleaseVersion, buildMetada // 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 } @@ -155,7 +300,7 @@ func Parse(version string) (Version, error) { var err error // Parse Major - v.Major, index, err = parseNumericIdentifier(version, index, length) + v.Major, index, err = p.parseNumericIdentifier(version, index, length) if err != nil { return Version{}, err } @@ -167,7 +312,7 @@ func Parse(version string) (Version, error) { index++ // Skip '.' // Parse Minor - v.Minor, index, err = parseNumericIdentifier(version, index, length) + v.Minor, index, err = p.parseNumericIdentifier(version, index, length) if err != nil { return Version{}, err } @@ -179,14 +324,14 @@ func Parse(version string) (Version, error) { index++ // Skip '.' // Parse Patch - v.Patch, index, err = parseNumericIdentifier(version, index, length) + v.Patch, index, err = p.parseNumericIdentifier(version, index, length) if err != nil { return Version{}, err } // Parse PreRelease and BuildMetadata if any if index < length { - index, err = parsePreReleaseAndBuildMetadata(version, index, length, &v) + index, err = p.parsePreReleaseAndBuildMetadata(version, index, length, &v) if err != nil { return Version{}, err } @@ -201,7 +346,7 @@ func Parse(version string) (Version, error) { // 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 parseNumericIdentifier(version string, index int, length int) (uint64, int, error) { +func (p *parser) parseNumericIdentifier(version string, index int, length int) (uint64, int, error) { if index >= length { return 0, index, ErrUnexpectedEndOfInput } @@ -230,7 +375,7 @@ func parseNumericIdentifier(version string, index int, length int) (uint64, int, // 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 parsePreReleaseAndBuildMetadata(version string, index int, length int, v *Version) (int, error) { +func (p *parser) parsePreReleaseAndBuildMetadata(version string, index int, length int, v *Version) (int, error) { var err error // Parse PreRelease if present @@ -244,7 +389,7 @@ func parsePreReleaseAndBuildMetadata(version string, index int, length int, v *V index++ } prerelease := version[start:index] - v.PreRelease, err = parsePrerelease(prerelease) + v.PreRelease, err = p.parsePrerelease(prerelease) if err != nil { return index, err } @@ -255,7 +400,7 @@ func parsePreReleaseAndBuildMetadata(version string, index int, length int, v *V index++ // Skip '+' start := index build := version[start:] - v.BuildMetadata, err = parseBuildMetadata(build) + v.BuildMetadata, err = p.parseBuildMetadata(build) if err != nil { return index, err } @@ -282,7 +427,7 @@ func parsePreReleaseAndBuildMetadata(version string, index int, length int, v *V // if err != nil { // // handle error // } -func parsePrerelease(s string) ([]PrereleaseVersion, error) { +func (p *parser) parsePrerelease(s string) ([]PrereleaseVersion, error) { if len(s) == 0 { return nil, ErrEmptyPrereleaseIdentifier } @@ -298,7 +443,7 @@ func parsePrerelease(s string) ([]PrereleaseVersion, error) { } part := s[start:i] - if !isValidPrereleaseIdentifier(part) { + if !p.isValidPrereleaseIdentifier(part) { return nil, ErrInvalidPrereleaseIdentifier } component, err := NewPrereleaseVersion(part) @@ -309,7 +454,7 @@ func parsePrerelease(s string) ([]PrereleaseVersion, error) { prerelease = append(prerelease, component) start = i + 1 - } else if s[i] > 127 || !isAllowedInIdentifier(s[i]) { + } else if s[i] > 127 || !p.isAllowedInIdentifier(s[i]) { return nil, ErrInvalidCharacterInIdentifier } } @@ -332,7 +477,7 @@ func parsePrerelease(s string) ([]PrereleaseVersion, error) { // if err != nil { // // handle error // } -func parseBuildMetadata(s string) ([]string, error) { +func (p *parser) parseBuildMetadata(s string) ([]string, error) { if len(s) == 0 { return nil, ErrEmptyBuildMetadata } @@ -347,12 +492,12 @@ func parseBuildMetadata(s string) ([]string, error) { return nil, ErrEmptyBuildMetadata } part := s[start:i] - if !isValidBuildIdentifier(part) { + if !p.isValidBuildIdentifier(part) { return nil, ErrInvalidBuildMetadataIdentifier } buildMetadata = append(buildMetadata, part) start = i + 1 - } else if s[i] > 127 || !isAllowedInIdentifier(s[i]) { + } else if s[i] > 127 || !p.isAllowedInIdentifier(s[i]) { return nil, ErrInvalidCharacterInIdentifier } } @@ -365,7 +510,7 @@ func parseBuildMetadata(s string) ([]string, error) { // - Uppercase letters ('A'-'Z') // - Lowercase letters ('a'-'z') // - Hyphen ('-') -func isAllowedInIdentifier(ch byte) bool { +func (p *parser) isAllowedInIdentifier(ch byte) bool { return (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || @@ -375,17 +520,17 @@ func isAllowedInIdentifier(ch byte) bool { // 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 isValidPrereleaseIdentifier(s string) bool { +func (p *parser) isValidPrereleaseIdentifier(s string) bool { if len(s) == 0 { return false } for i := 0; i < len(s); i++ { ch := s[i] - if !isAllowedInIdentifier(ch) { + if !p.isAllowedInIdentifier(ch) { return false } } - if isNumeric(s) && s[0] == '0' && len(s) > 1 { + if p.config.StrictAdherence() && isNumeric(s) && s[0] == '0' && len(s) > 1 { return false // Leading zeros are not allowed in numeric identifiers } @@ -394,13 +539,13 @@ func isValidPrereleaseIdentifier(s string) bool { // isValidBuildIdentifier checks if a build metadata identifier is valid. // The identifier must not be empty and must contain only allowed characters. -func isValidBuildIdentifier(s string) bool { +func (p *parser) isValidBuildIdentifier(s string) bool { if len(s) == 0 { return false } for i := 0; i < len(s); i++ { ch := s[i] - if !isAllowedInIdentifier(ch) { + if !p.isAllowedInIdentifier(ch) { return false } } @@ -578,16 +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. +// Config holds the runtime configuration for the Nano ID generator. // -// 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 +// 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 0690d97..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" @@ -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 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) + } + }) + } }