From b4ca01580a82360c3bfacec728384ae59aa53cd6 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 21:56:56 +0200 Subject: [PATCH 1/6] fix: reject invalid SemVer identifiers when parsing Per SemVer 2.0.0 the core version numbers and numeric prerelease identifiers must not include leading zeroes, and prerelease/build-metadata identifiers must be composed only of ASCII alphanumerics and hyphens. Add IsValidIdentifiers using a vectorized SearchValues lookup for the allowed character set and ulong.TryParse to detect numeric identifiers for the leading-zero check. Add failing-then-passing tests for both invalid and still-valid inputs. --- CHANGELOG.md | 4 ++ src/semver.tests/SemanticVersionTests.cs | 26 +++++++++ src/semver/SemanticVersion.Parsing.cs | 69 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba22dd..e1784b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +### Fixed + +- Reject invalid SemVer identifiers when parsing versions. + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/semver/releases/tag/1.0.0 diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index fe09dd8..1cb7eed 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -186,6 +186,32 @@ 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_handle_invalid_json_token() { diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs index cb311aa..e24802e 100644 --- a/src/semver/SemanticVersion.Parsing.cs +++ b/src/semver/SemanticVersion.Parsing.cs @@ -85,6 +85,9 @@ public readonly partial record struct SemanticVersion : ISpanParsable ValidIdentifierChars = + System.Buffers.SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-"); + internal static bool TryParsePartially(ReadOnlySpan s, out SemanticVersion version, out int components) { version = default; @@ -146,10 +149,76 @@ public readonly partial record struct SemanticVersion : ISpanParsable 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; + } } From 2ea7866e3b10a7ea813d9f5eaf0d5181f4364c49 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 21:57:12 +0200 Subject: [PATCH 2/6] fix: range exact/non-equal matches ignore build metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build metadata does not affect precedence (SemVer 2.0.0 §10), so the Eq and Neq constraints now use SemanticVersionComparer.Priority instead of the structural == operator. This makes [1.2.3] match 1.2.3+build, consistent with precedence semantics. Add a failing-then-passing test. --- CHANGELOG.md | 1 + src/semver.tests/SemanticVersionRangeTests.cs | 9 +++++++++ src/semver/SemanticVersionRange.cs | 6 ++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1784b3..32f4f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - Reject invalid SemVer identifiers when parsing versions. +- Range exact and non-equal comparators ignore build metadata when matching versions. ### Removed diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index 2fb4c13..5bc065f 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -248,6 +248,15 @@ 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] public async Task I_fail_formatting_when_the_tentative_short_form_overflows() { diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index 3e56048..fa1ee37 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -50,8 +50,10 @@ internal readonly struct Constraint { return Operation switch { - Comparison.Eq => v == Version, - Comparison.Neq => v != Version, + // 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.Lt => v < Version, Comparison.Lte => v <= Version, Comparison.Gt => v > Version, From ae2163188feb52ff2935d5c879cac2b2a5bbd5a3 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 21:57:23 +0200 Subject: [PATCH 3/6] fix: avoid infinite recursion when formatting ranges as short npm TryFormat for the "ns" format returned false for any set that can not be expressed as a caret/tilde range. The public ToString routes through DefaultInterpolatedStringHandler, whose buffer-full fallback re-enters ToString, causing an infinite loop / stack overflow. For "ns", fall back to the full comparator ("n") form when the compact form cannot represent the set, so the call always succeeds for a representable range. A genuine buffer overflow still returns false as before. Add failing-then-passing tests. --- CHANGELOG.md | 1 + src/semver.tests/SemanticVersionRangeTests.cs | 15 +++++++++++++++ src/semver/SemanticVersionRange.Formatting.cs | 19 ++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f4f5c..c4cc6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - 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 diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index 5bc065f..ea392e1 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -257,6 +257,21 @@ internal sealed class SemanticVersionRangeTests 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() { diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index 4071601..8b1c570 100644 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -85,9 +85,26 @@ public readonly partial record 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])) { - return false; + if (buf.Written == writtenBefore) + { + if (!TryFormatNormalNpm(ref buf, sets[i])) + { + return false; + } + } + else + { + return false; + } } } else From 95eb4f05a2d252f83e8ca0e8889e642a25ac7714 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 21:57:45 +0200 Subject: [PATCH 4/6] fix: make ==/Equals ignore build metadata for precedence Previously SemanticVersion was a record struct whose ==/Equals/ GetHashCode were synthesized from all fields, so versions that differ only in build metadata were not equal. Range matching already used precedence (ignoring metadata), creating an inconsistency. Change the type to a plain readonly struct implementing IEquatable and define ==/Equals/GetHashCode in terms of SemanticVersionComparer.Priority, so build metadata is ignored. The custom ToString already existed, so no display behaviour is lost. Add a failing-then-passing test. --- CHANGELOG.md | 2 ++ src/semver.tests/SemanticVersionTests.cs | 13 ++++++++ src/semver/SemanticVersion.Comparison.cs | 2 +- src/semver/SemanticVersion.Formatting.cs | 2 +- src/semver/SemanticVersion.JsonConverter.cs | 2 +- src/semver/SemanticVersion.Parsing.cs | 2 +- src/semver/SemanticVersion.cs | 36 ++++++++++++++++++++- 7 files changed, 54 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cc6a8..5db6072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +- `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules. + ### Fixed - Reject invalid SemVer identifiers when parsing versions. diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index 1cb7eed..587d643 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -212,6 +212,19 @@ internal sealed class SemanticVersionTests 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() { diff --git a/src/semver/SemanticVersion.Comparison.cs b/src/semver/SemanticVersion.Comparison.cs index 6e2a43b..8c6d7e4 100644 --- a/src/semver/SemanticVersion.Comparison.cs +++ b/src/semver/SemanticVersion.Comparison.cs @@ -3,7 +3,7 @@ namespace Geekeey.SemVer; -public readonly partial record struct SemanticVersion : IComparable, IComparable +public readonly partial struct SemanticVersion : IComparable, IComparable { /// public int CompareTo(object? obj) diff --git a/src/semver/SemanticVersion.Formatting.cs b/src/semver/SemanticVersion.Formatting.cs index 890a35d..5634b27 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 record struct SemanticVersion : ISpanFormattable +public readonly partial struct SemanticVersion : ISpanFormattable { /// public override string ToString() diff --git a/src/semver/SemanticVersion.JsonConverter.cs b/src/semver/SemanticVersion.JsonConverter.cs index a0c200c..c677e3a 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 record struct SemanticVersion +public readonly partial struct SemanticVersion { internal sealed class SemanticVersionJsonConverter : JsonConverter { diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs index e24802e..ab43eb3 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 record struct SemanticVersion : ISpanParsable +public readonly partial struct SemanticVersion : ISpanParsable { #region IParsable diff --git a/src/semver/SemanticVersion.cs b/src/semver/SemanticVersion.cs index bf25b83..22311b3 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 record struct SemanticVersion +public readonly partial struct SemanticVersion : IEquatable { /// /// Converts a into the equivalent semantic version. @@ -105,4 +105,38 @@ public readonly partial record struct SemanticVersion /// 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); + } } From f9531fe112d85cb14c493544b70b3fae6d40eddb Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 22:04:15 +0200 Subject: [PATCH 5/6] fix: make SemanticVersionRange equality structural The type was a record struct whose ==/Equals/GetHashCode were synthesized from all fields, including the _sets array, so two ranges that parse to the same constraint sets compared as unequal (reference-sensitive on the array). This also broke using ranges as dictionary keys, where lookup relies on value equality. Change the type to a plain readonly struct implementing IEquatable with structural equality over the constraint sets (operation + version per comparator). The custom ToString already existed, so no display behaviour is lost. Add a failing-then-passing test. --- CHANGELOG.md | 3 + src/semver.tests/SemanticVersionRangeTests.cs | 10 +++ src/semver/SemanticVersionRange.Formatting.cs | 2 +- .../SemanticVersionRange.JsonConverter.cs | 2 +- src/semver/SemanticVersionRange.Parsing.cs | 2 +- src/semver/SemanticVersionRange.cs | 84 ++++++++++++++++++- 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db6072..8837c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### 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 diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index ea392e1..ec11063 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -282,4 +282,14 @@ internal sealed class SemanticVersionRangeTests await Assert.That(success).IsFalse(); await Assert.That(charsWritten).IsEqualTo(0); } + + [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/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index 8b1c570..d35d44e 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 record struct SemanticVersionRange : ISpanFormattable +public readonly partial struct SemanticVersionRange : ISpanFormattable { /// public override string ToString() diff --git a/src/semver/SemanticVersionRange.JsonConverter.cs b/src/semver/SemanticVersionRange.JsonConverter.cs index ff95e73..d94d305 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 record struct SemanticVersionRange +public readonly partial struct SemanticVersionRange { internal sealed class SemanticVersionRangeJsonConverter : JsonConverter { diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs index 098f1f4..8234747 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 record struct SemanticVersionRange : ISpanParsable +public readonly partial struct SemanticVersionRange : ISpanParsable { #region IParsable diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index fa1ee37..3a8432b 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -1,13 +1,15 @@ // 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 record struct SemanticVersionRange +public readonly partial struct SemanticVersionRange : IEquatable { // OR of AND-groups. null == empty range (matches nothing). private readonly ConstraintSet[]? _sets; @@ -31,6 +33,86 @@ public readonly partial record struct SemanticVersionRange return _sets.Any(set => 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 } From fba1476d9c01fb271e318f298fc051f48b685e26 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 22:05:15 +0200 Subject: [PATCH 6/6] feat: support SemanticVersion and SemanticVersionRange as JSON dictionary keys System.Text.Json requires custom converters to override ReadAsPropertyName/WriteAsPropertyName in order to serialize types used as dictionary keys. Add those overrides to both converters so SemanticVersion and SemanticVersionRange can round trip as Dictionary keys. Add failing-then-passing tests for both key types. --- CHANGELOG.md | 4 +++ src/semver.tests/SemanticVersionRangeTests.cs | 20 +++++++++++++ src/semver.tests/SemanticVersionTests.cs | 19 ++++++++++++- src/semver/SemanticVersion.JsonConverter.cs | 28 +++++++++++++++++++ .../SemanticVersionRange.JsonConverter.cs | 28 +++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8837c0f..c410ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ 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. diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index ec11063..d5d92a6 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -1,6 +1,8 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Text.Json; + namespace Geekeey.SemVer.Tests; internal sealed class SemanticVersionRangeTests @@ -283,6 +285,24 @@ internal sealed class SemanticVersionRangeTests 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() { diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index 587d643..c3f9c73 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -257,8 +257,25 @@ 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.JsonConverter.cs b/src/semver/SemanticVersion.JsonConverter.cs index c677e3a..8eb1797 100644 --- a/src/semver/SemanticVersion.JsonConverter.cs +++ b/src/semver/SemanticVersion.JsonConverter.cs @@ -37,5 +37,33 @@ 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/SemanticVersionRange.JsonConverter.cs b/src/semver/SemanticVersionRange.JsonConverter.cs index d94d305..f3bcd40 100644 --- a/src/semver/SemanticVersionRange.JsonConverter.cs +++ b/src/semver/SemanticVersionRange.JsonConverter.cs @@ -37,5 +37,33 @@ 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()); + } } }