From 4f355aabe33c4f4856a8b13b0a2cae167e6ad6b7 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 13 Sep 2026 08:35:48 -0400 Subject: [PATCH] Remove allocations from SemVer scheme validation --- normalization.go | 10 +++--- semver_validation_test.go | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 semver_validation_test.go diff --git a/normalization.go b/normalization.go index c075960..68fd673 100644 --- a/normalization.go +++ b/normalization.go @@ -89,16 +89,16 @@ func normalizeVersionForScheme(version, scheme string) (string, error) { } func validSemverLike(s string) bool { - m := SemanticVersionRegex.FindStringSubmatch(s) - if m == nil { + parsed, ok := parseSemverValue(s) + if !ok { return false } - for _, field := range []string{m[4], m[5]} { + for _, field := range []string{parsed.pre, semverBuild(s)} { if field == "" { continue } - for _, part := range strings.Split(field, ".") { - if part == "" || strings.Trim(part, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-") != "" { + for part := range strings.SplitSeq(field, ".") { + if part == "" || !validGoIdentifier(part) { return false } } diff --git a/semver_validation_test.go b/semver_validation_test.go new file mode 100644 index 0000000..1bbcf91 --- /dev/null +++ b/semver_validation_test.go @@ -0,0 +1,75 @@ +package vers + +import ( + "strings" + "testing" +) + +func TestSemverValidation(t *testing.T) { + tests := []struct { + version string + valid bool + }{ + {"1", true}, {"1.2", true}, {"1.2.3", true}, + {"v01.002.0003", true}, {"1.2.3-alpha.01+build.5", true}, + {"1.2.3--+0", true}, {"\t1.2.3\r\n", true}, + {"", false}, {"v", false}, {"V1.2.3", false}, + {"1.2.3.4", false}, {"1.", false}, {"1..2", false}, + {"1.2.3-", false}, {"1.2.3+", false}, + {"1.2.3-alpha..1", false}, {"1.2.3+build..1", false}, + {"1.2.3-alpha_1", false}, {"1.2.3+é", false}, + {"1.2.3-alpha\n1", false}, {"1.2.3+a+b", false}, + {"^1.2.3", false}, {"1.2.x", false}, + } + for _, scheme := range []string{"npm", "semver", "cargo", "go", "golang", "hex", "elixir"} { + t.Run(scheme, func(t *testing.T) { + for _, tt := range tests { + if got := ValidWithScheme(tt.version, scheme); got != tt.valid { + t.Errorf("ValidWithScheme(%q, %q) = %v, want %v", tt.version, scheme, got, tt.valid) + } + } + }) + } +} + +func FuzzSemverValidation(f *testing.F) { + for _, version := range []string{"1.2.3", "v01.2", "1.2.3-alpha.01+build.5", "1.2.3-", "1.2.3+", "1.2.3-a\nb", "1.2.3+a+b", "", "1.2.3.4"} { + f.Add(version) + } + f.Fuzz(func(t *testing.T, version string) { + want := referenceSemverValidation(strings.TrimSpace(version)) + if got := ValidWithScheme(version, "npm"); got != want { + t.Fatalf("ValidWithScheme(%q, npm) = %v, want %v", version, got, want) + } + }) +} + +// Preserve the regex implementation as an independent compatibility oracle. +func referenceSemverValidation(version string) bool { + m := SemanticVersionRegex.FindStringSubmatch(version) + if m == nil { + return false + } + for _, field := range []string{m[4], m[5]} { + if field == "" { + continue + } + for _, part := range strings.Split(field, ".") { + if part == "" || strings.Trim(part, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-") != "" { + return false + } + } + } + return true +} + +func BenchmarkValidWithSchemeNPM(b *testing.B) { + for _, version := range []string{"1.2.3", "v01.2", "1.2.3-alpha.1+build.5", "1.2.3-alpha..1"} { + b.Run(version, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + ValidWithScheme(version, "npm") + } + }) + } +}