diff --git a/CHANGELOG.md b/CHANGELOG.md index c410ad5..1ba22dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,23 +14,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added -- `SemanticVersion` and `SemanticVersionRange` can now be used as `System.Text.Json` - dictionary keys. The custom converters override `ReadAsPropertyName`/`WriteAsPropertyName` - so `Dictionary` round-trips correctly. - ### Changed -- `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules. -- `SemanticVersionRange` equality (`==`, `Equals`, `GetHashCode`) is now structural and based - on the constraint sets, so ranges that parse to the same comparators compare as equal - (previously it was reference-sensitive on the backing array). - -### Fixed - -- Reject invalid SemVer identifiers when parsing versions. -- Range exact and non-equal comparators ignore build metadata when matching versions. -- Avoid infinite recursion when formatting ranges in the short npm (`ns`) form. - ### Removed [1.0.0]: https://code.geekeey.de/geekeey/semver/releases/tag/1.0.0 diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index d5d92a6..2fb4c13 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -1,8 +1,6 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 -using System.Text.Json; - namespace Geekeey.SemVer.Tests; internal sealed class SemanticVersionRangeTests @@ -250,30 +248,6 @@ internal sealed class SemanticVersionRangeTests await Assert.That(new string(destination[..charsWritten])).IsEqualTo("^1.2.3"); } - [Test] - public async Task I_can_match_exact_range_ignoring_build_metadata() - { - var range = SemanticVersionRange.Parse("[1.2.3]"); - - await Assert.That(range.Contains(SemanticVersion.Parse("1.2.3+build.7"))).IsTrue(); - await Assert.That(range.Contains(SemanticVersion.Parse("1.2.4"))).IsFalse(); - } - - [Test] - [Arguments("[1.2.3,1.4.0)", ">=1.2.3 <1.4.0")] - [Arguments(">1.0.0", ">1.0.0")] - [Arguments("[1.2,1.3]", ">=1.2.0 <=1.3.0")] - [Arguments("(,1.4.0]", "<=1.4.0")] - public async Task I_can_format_ranges_as_short_npm_even_when_not_caret_tilde(string range, string expected) - { - var value = SemanticVersionRange.Parse(range); - var destination = new char[256]; - var success = value.TryFormat(destination, out var charsWritten, "ns", null); - - await Assert.That(success).IsTrue(); - await Assert.That(new string(destination[..charsWritten])).IsEqualTo(expected); - } - [Test] public async Task I_fail_formatting_when_the_tentative_short_form_overflows() { @@ -284,32 +258,4 @@ internal sealed class SemanticVersionRangeTests await Assert.That(success).IsFalse(); await Assert.That(charsWritten).IsEqualTo(0); } - - [Test] - public async Task I_can_serialize_range_as_dictionary_key() - { - var dict = new Dictionary - { - [SemanticVersionRange.Parse("^1.2.3")] = 1, - [SemanticVersionRange.Parse("^2.0.0")] = 2, - }; - - var json = JsonSerializer.Serialize(dict); - var roundTripped = JsonSerializer.Deserialize>(json); - - await Assert.That(roundTripped).IsNotNull(); - await Assert.That(roundTripped!.Count).IsEqualTo(2); - await Assert.That(roundTripped[SemanticVersionRange.Parse("^1.2.3")]).IsEqualTo(1); - await Assert.That(roundTripped[SemanticVersionRange.Parse("^2.0.0")]).IsEqualTo(2); - } - - [Test] - public async Task I_can_treat_structurally_equal_ranges_as_equal() - { - var a = SemanticVersionRange.Parse("^1.2.3"); - var b = SemanticVersionRange.Parse("[1.2.3,2.0.0)"); - - await Assert.That(a == b).IsTrue(); - await Assert.That(a.GetHashCode()).IsEqualTo(b.GetHashCode()); - } } diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index c3f9c73..fe09dd8 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -186,45 +186,6 @@ internal sealed class SemanticVersionTests await Assert.That(v.Metadata).IsEqualTo("789"); } - [Test] - [Arguments("1.2.3-@#")] - [Arguments("01.2.3")] - [Arguments("1.2.3-01")] - [Arguments("1.2.3-alpha_1")] - [Arguments("1.2.3+build.@")] - [Arguments("1.2.3-")] - [Arguments("1.2.3-..")] - public async Task I_can_not_parse_invalid_versions(string input) - { - await Assert.That(SemanticVersion.TryParse(input, out _)).IsFalse(); - } - - [Test] - [Arguments("1.2.3")] - [Arguments("1.2.3-alpha.1")] - [Arguments("1.0.0-0.3.7")] - [Arguments("1.0.0-x.7.z.92")] - [Arguments("10.20.30")] - [Arguments("1.2.3+build.01")] - [Arguments("1.0.0-alpha-b")] - public async Task I_can_parse_valid_versions(string input) - { - await Assert.That(SemanticVersion.TryParse(input, out _)).IsTrue(); - } - - [Test] - public async Task I_can_treat_versions_as_equal_when_only_build_metadata_differs() - { - var a = SemanticVersion.Parse("1.2.3"); - var b = SemanticVersion.Parse("1.2.3+build.7"); - - await Assert.That(a == b).IsTrue(); - await Assert.That(a.GetHashCode()).IsEqualTo(b.GetHashCode()); - - var c = SemanticVersion.Parse("1.2.3-alpha"); - await Assert.That(a == c).IsFalse(); - } - [Test] public async Task I_can_handle_invalid_json_token() { @@ -257,25 +218,8 @@ internal sealed class SemanticVersionTests public async Task I_can_deserialize_empty_string() { var json = "\"\""; + await Assert.That(() => JsonSerializer.Deserialize(json)) .Throws(); } - - [Test] - public async Task I_can_serialize_version_as_dictionary_key() - { - var dict = new Dictionary - { - [new SemanticVersion(1, 2, 3)] = 1, - [new SemanticVersion(2, 0, 0, "rc.1", "build")] = 2, - }; - - var json = JsonSerializer.Serialize(dict); - var roundTripped = JsonSerializer.Deserialize>(json); - - await Assert.That(roundTripped).IsNotNull(); - await Assert.That(roundTripped!.Count).IsEqualTo(2); - await Assert.That(roundTripped[new SemanticVersion(1, 2, 3)]).IsEqualTo(1); - await Assert.That(roundTripped[new SemanticVersion(2, 0, 0, "rc.1", "build")]).IsEqualTo(2); - } } diff --git a/src/semver/SemanticVersion.Comparison.cs b/src/semver/SemanticVersion.Comparison.cs index 8c6d7e4..6e2a43b 100644 --- a/src/semver/SemanticVersion.Comparison.cs +++ b/src/semver/SemanticVersion.Comparison.cs @@ -3,7 +3,7 @@ namespace Geekeey.SemVer; -public readonly partial struct SemanticVersion : IComparable, IComparable +public readonly partial record struct SemanticVersion : IComparable, IComparable { /// public int CompareTo(object? obj) diff --git a/src/semver/SemanticVersion.Formatting.cs b/src/semver/SemanticVersion.Formatting.cs index 5634b27..890a35d 100644 --- a/src/semver/SemanticVersion.Formatting.cs +++ b/src/semver/SemanticVersion.Formatting.cs @@ -5,7 +5,7 @@ using System.Runtime.CompilerServices; namespace Geekeey.SemVer; -public readonly partial struct SemanticVersion : ISpanFormattable +public readonly partial record struct SemanticVersion : ISpanFormattable { /// public override string ToString() diff --git a/src/semver/SemanticVersion.JsonConverter.cs b/src/semver/SemanticVersion.JsonConverter.cs index 8eb1797..a0c200c 100644 --- a/src/semver/SemanticVersion.JsonConverter.cs +++ b/src/semver/SemanticVersion.JsonConverter.cs @@ -7,7 +7,7 @@ using System.Text.Json.Serialization; namespace Geekeey.SemVer; [JsonConverter(typeof(SemanticVersionJsonConverter))] -public readonly partial struct SemanticVersion +public readonly partial record struct SemanticVersion { internal sealed class SemanticVersionJsonConverter : JsonConverter { @@ -37,33 +37,5 @@ public readonly partial struct SemanticVersion { writer.WriteStringValue(value.ToString("f", null)); } - - public override SemanticVersion ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType is JsonTokenType.Null) - { - return default; - } - - var name = reader.GetString(); - if (name is null) - { - return default; - } - - try - { - return Parse(name); - } - catch (FormatException exception) - { - throw new JsonException(exception.Message, exception); - } - } - - public override void WriteAsPropertyName(Utf8JsonWriter writer, SemanticVersion value, JsonSerializerOptions options) - { - writer.WritePropertyName(value.ToString("f", null)); - } } } diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs index ab43eb3..cb311aa 100644 --- a/src/semver/SemanticVersion.Parsing.cs +++ b/src/semver/SemanticVersion.Parsing.cs @@ -6,7 +6,7 @@ using System.Globalization; namespace Geekeey.SemVer; -public readonly partial struct SemanticVersion : ISpanParsable +public readonly partial record struct SemanticVersion : ISpanParsable { #region IParsable @@ -85,9 +85,6 @@ public readonly partial struct SemanticVersion : ISpanParsable // components = 1: wildcard at minor // components = 2: wildcard at patch // components = 3: no wildcards - private static readonly System.Buffers.SearchValues ValidIdentifierChars = - System.Buffers.SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-"); - internal static bool TryParsePartially(ReadOnlySpan s, out SemanticVersion version, out int components) { version = default; @@ -149,76 +146,10 @@ public readonly partial struct SemanticVersion : ISpanParsable return false; } - // Leading zeroes are forbidden for the numeric core components (SemVer 2.0.0 §9). - if (segment.Length > 1 && segment[0] is '0') - { - return false; - } - component[components++] = value; } - // Per SemVer 2.0.0 the core version numbers and numeric prerelease identifiers - // MUST NOT include leading zeroes, and prerelease/metadata identifiers MUST be - // composed only of ASCII alphanumerics and hyphens. - if (metadata is not null && !IsValidIdentifiers(metadata, forbidLeadingZeroInNumerics: false)) - { - return false; - } - - if (prerelease is not null && !IsValidIdentifiers(prerelease, forbidLeadingZeroInNumerics: true)) - { - return false; - } - version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata); return true; } - - private static bool IsValidIdentifiers(ReadOnlySpan value, bool forbidLeadingZeroInNumerics) - { - if (value.IsEmpty) - { - return false; - } - - // Validation is per-identifier (dot-separated), not per-string: an empty - // segment ("a..b", "a.") is invalid, and the leading-zero ban applies only - // to numeric identifiers (so "0abc" stays valid while "02" does not). - var start = 0; - for (var i = 0; i <= value.Length; i++) - { - if (i != value.Length && value[i] is not '.') - { - continue; - } - - var identifier = value[start..i]; - - // Reject empty segments produced by adjacent or trailing dots. - if (identifier.IsEmpty) - { - return false; - } - - // Every character must be an allowed identifier character. - if (identifier.IndexOfAnyExcept(ValidIdentifierChars) >= 0) - { - return false; - } - - // Numeric identifiers must not include leading zeroes (SemVer 2.0.0 §9/§10). - // The TryParse gate ensures the rule fires only for all-digit identifiers, - // leaving an alphanumeric "0abc" (which is allowed) untouched. - if (forbidLeadingZeroInNumerics && identifier.Length > 1 && identifier[0] is '0' && - ulong.TryParse(identifier, NumberStyles.None, CultureInfo.InvariantCulture, out _)) - { - return false; - } - - start = i + 1; - } - - return true; - } } diff --git a/src/semver/SemanticVersion.cs b/src/semver/SemanticVersion.cs index 22311b3..bf25b83 100644 --- a/src/semver/SemanticVersion.cs +++ b/src/semver/SemanticVersion.cs @@ -6,7 +6,7 @@ namespace Geekeey.SemVer; /// /// Represents a semantic version that adheres to the Semantic Versioning 2.0.0 specification. /// -public readonly partial struct SemanticVersion : IEquatable +public readonly partial record struct SemanticVersion { /// /// Converts a into the equivalent semantic version. @@ -105,38 +105,4 @@ public readonly partial struct SemanticVersion : IEquatable /// details. It does not affect the precedence of the version. /// public string? Metadata { get; } - - /// - public bool Equals(SemanticVersion other) - { - return SemanticVersionComparer.Priority.Equals(this, other); - } - - /// - public override bool Equals(object? obj) - { - return obj is SemanticVersion other && Equals(other); - } - - /// - public override int GetHashCode() - { - return SemanticVersionComparer.Priority.GetHashCode(this); - } - - /// - /// Determines whether two versions have equal precedence. - /// - public static bool operator ==(SemanticVersion left, SemanticVersion right) - { - return left.Equals(right); - } - - /// - /// Determines whether two versions differ in precedence. - /// - public static bool operator !=(SemanticVersion left, SemanticVersion right) - { - return !left.Equals(right); - } } diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index d35d44e..4071601 100644 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -5,7 +5,7 @@ using System.Runtime.CompilerServices; namespace Geekeey.SemVer; -public readonly partial struct SemanticVersionRange : ISpanFormattable +public readonly partial record struct SemanticVersionRange : ISpanFormattable { /// public override string ToString() @@ -85,26 +85,9 @@ public readonly partial struct SemanticVersionRange : ISpanFormattable if (format is "ns") { - var writtenBefore = buf.Written; - - // Try the compact caret/tilde form first. If the set can't be expressed - // that way (TryFormatSimpleNpm returns false without writing anything) fall - // back to the full comparator form so we never report failure for a - // representable range - otherwise the public ToString would recurse - // infinitely via the interpolated-string handler's buffer-overflow fallback. if (!TryFormatSimpleNpm(ref buf, sets[i])) { - if (buf.Written == writtenBefore) - { - if (!TryFormatNormalNpm(ref buf, sets[i])) - { - return false; - } - } - else - { - return false; - } + return false; } } else diff --git a/src/semver/SemanticVersionRange.JsonConverter.cs b/src/semver/SemanticVersionRange.JsonConverter.cs index f3bcd40..ff95e73 100644 --- a/src/semver/SemanticVersionRange.JsonConverter.cs +++ b/src/semver/SemanticVersionRange.JsonConverter.cs @@ -7,7 +7,7 @@ using System.Text.Json.Serialization; namespace Geekeey.SemVer; [JsonConverter(typeof(SemanticVersionRangeJsonConverter))] -public readonly partial struct SemanticVersionRange +public readonly partial record struct SemanticVersionRange { internal sealed class SemanticVersionRangeJsonConverter : JsonConverter { @@ -37,33 +37,5 @@ public readonly partial struct SemanticVersionRange { writer.WriteStringValue(value.ToString()); } - - public override SemanticVersionRange ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType is JsonTokenType.Null) - { - return default; - } - - var name = reader.GetString(); - if (name is null) - { - return default; - } - - try - { - return Parse(name); - } - catch (FormatException exception) - { - throw new JsonException(exception.Message, exception); - } - } - - public override void WriteAsPropertyName(Utf8JsonWriter writer, SemanticVersionRange value, JsonSerializerOptions options) - { - writer.WritePropertyName(value.ToString()); - } } } diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs index 8234747..098f1f4 100644 --- a/src/semver/SemanticVersionRange.Parsing.cs +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -5,7 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace Geekeey.SemVer; -public readonly partial struct SemanticVersionRange : ISpanParsable +public readonly partial record struct SemanticVersionRange : ISpanParsable { #region IParsable diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index 3a8432b..3e56048 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -1,15 +1,13 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 -using System; - namespace Geekeey.SemVer; /// /// Represents a semantic version range, which is a set of version constraints /// used to match specific semantic versions based on defined ranges or patterns. /// -public readonly partial struct SemanticVersionRange : IEquatable +public readonly partial record struct SemanticVersionRange { // OR of AND-groups. null == empty range (matches nothing). private readonly ConstraintSet[]? _sets; @@ -33,86 +31,6 @@ public readonly partial struct SemanticVersionRange : IEquatable set.Includes(version)); } - - /// - public bool Equals(SemanticVersionRange other) - { - if (_sets is null || other._sets is null) - { - return _sets is null && other._sets is null; - } - - if (_sets.Length != other._sets.Length) - { - return false; - } - - for (var i = 0; i < _sets.Length; i++) - { - if (!Equals(_sets[i], other._sets[i])) - { - return false; - } - } - - return true; - } - - private static bool Equals(ConstraintSet x, ConstraintSet y) - { - if (x.Constraints.Length != y.Constraints.Length) - { - return false; - } - - for (var i = 0; i < x.Constraints.Length; i++) - { - if (x.Constraints[i].Operation != y.Constraints[i].Operation || - x.Constraints[i].Version != y.Constraints[i].Version) - { - return false; - } - } - - return true; - } - - /// - public override bool Equals(object? obj) - => obj is SemanticVersionRange other && Equals(other); - - /// - public override int GetHashCode() - { - if (_sets is null) - { - return 0; - } - - var hash = new HashCode(); - foreach (var set in _sets) - { - foreach (var constraint in set.Constraints) - { - hash.Add(constraint.Operation); - hash.Add(constraint.Version); - } - } - - return hash.ToHashCode(); - } - - /// - /// Determines whether two semantic version ranges are equal. - /// - public static bool operator ==(SemanticVersionRange left, SemanticVersionRange right) - => left.Equals(right); - - /// - /// Determines whether two semantic version ranges differ. - /// - public static bool operator !=(SemanticVersionRange left, SemanticVersionRange right) - => !left.Equals(right); } internal enum Comparison { Eq, Neq, Lt, Lte, Gt, Gte } @@ -132,10 +50,8 @@ internal readonly struct Constraint { return Operation switch { - // Build metadata does not affect precedence (SemVer 2.0.0 §10), so exact - // and non-equal comparisons on the version part ignore it. - Comparison.Eq => SemanticVersionComparer.Priority.Equals(v, Version), - Comparison.Neq => !SemanticVersionComparer.Priority.Equals(v, Version), + Comparison.Eq => v == Version, + Comparison.Neq => v != Version, Comparison.Lt => v < Version, Comparison.Lte => v <= Version, Comparison.Gt => v > Version,