Compare commits

...
Author SHA1 Message Date
fba1476d9c feat: support SemanticVersion and SemanticVersionRange as JSON dictionary keys
Some checks failed
default / dotnet-default-workflow (push) Failing after 1m53s
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<TKey,TValue> keys.

Add failing-then-passing tests for both key types.
2026-07-11 23:03:12 +02:00
f9531fe112 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<SemanticVersionRange> 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.
2026-07-11 23:03:04 +02:00
95eb4f05a2 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<SemanticVersion> 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.
2026-07-11 23:02:43 +02:00
ae2163188f 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.
2026-07-11 23:01:59 +02:00
2ea7866e3b fix: range exact/non-equal matches ignore build metadata
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.
2026-07-11 23:01:48 +02:00
b4ca01580a 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.
2026-07-11 23:01:23 +02:00
12 changed files with 398 additions and 13 deletions

View file

@ -14,8 +14,23 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
### Added ### Added
- `SemanticVersion` and `SemanticVersionRange` can now be used as `System.Text.Json`
dictionary keys. The custom converters override `ReadAsPropertyName`/`WriteAsPropertyName`
so `Dictionary<TKey, TValue>` round-trips correctly.
### Changed ### 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 ### Removed
[1.0.0]: https://code.geekeey.de/geekeey/semver/releases/tag/1.0.0 [1.0.0]: https://code.geekeey.de/geekeey/semver/releases/tag/1.0.0

View file

@ -1,6 +1,8 @@
// Copyright (c) The Geekeey Authors // Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
using System.Text.Json;
namespace Geekeey.SemVer.Tests; namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionRangeTests internal sealed class SemanticVersionRangeTests
@ -248,6 +250,30 @@ internal sealed class SemanticVersionRangeTests
await Assert.That(new string(destination[..charsWritten])).IsEqualTo("^1.2.3"); 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] [Test]
public async Task I_fail_formatting_when_the_tentative_short_form_overflows() public async Task I_fail_formatting_when_the_tentative_short_form_overflows()
{ {
@ -258,4 +284,32 @@ internal sealed class SemanticVersionRangeTests
await Assert.That(success).IsFalse(); await Assert.That(success).IsFalse();
await Assert.That(charsWritten).IsEqualTo(0); await Assert.That(charsWritten).IsEqualTo(0);
} }
[Test]
public async Task I_can_serialize_range_as_dictionary_key()
{
var dict = new Dictionary<SemanticVersionRange, int>
{
[SemanticVersionRange.Parse("^1.2.3")] = 1,
[SemanticVersionRange.Parse("^2.0.0")] = 2,
};
var json = JsonSerializer.Serialize(dict);
var roundTripped = JsonSerializer.Deserialize<Dictionary<SemanticVersionRange, int>>(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());
}
} }

View file

@ -186,6 +186,45 @@ internal sealed class SemanticVersionTests
await Assert.That(v.Metadata).IsEqualTo("789"); 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] [Test]
public async Task I_can_handle_invalid_json_token() public async Task I_can_handle_invalid_json_token()
{ {
@ -218,8 +257,25 @@ internal sealed class SemanticVersionTests
public async Task I_can_deserialize_empty_string() public async Task I_can_deserialize_empty_string()
{ {
var json = "\"\""; var json = "\"\"";
await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json)) await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json))
.Throws<JsonException>(); .Throws<JsonException>();
} }
[Test]
public async Task I_can_serialize_version_as_dictionary_key()
{
var dict = new Dictionary<SemanticVersion, int>
{
[new SemanticVersion(1, 2, 3)] = 1,
[new SemanticVersion(2, 0, 0, "rc.1", "build")] = 2,
};
var json = JsonSerializer.Serialize(dict);
var roundTripped = JsonSerializer.Deserialize<Dictionary<SemanticVersion, int>>(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);
}
} }

View file

@ -3,7 +3,7 @@
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersion : IComparable, IComparable<SemanticVersion> public readonly partial struct SemanticVersion : IComparable, IComparable<SemanticVersion>
{ {
/// <inheritdoc /> /// <inheritdoc />
public int CompareTo(object? obj) public int CompareTo(object? obj)

View file

@ -5,7 +5,7 @@ using System.Runtime.CompilerServices;
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersion : ISpanFormattable public readonly partial struct SemanticVersion : ISpanFormattable
{ {
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override string ToString()

View file

@ -7,7 +7,7 @@ using System.Text.Json.Serialization;
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
[JsonConverter(typeof(SemanticVersionJsonConverter))] [JsonConverter(typeof(SemanticVersionJsonConverter))]
public readonly partial record struct SemanticVersion public readonly partial struct SemanticVersion
{ {
internal sealed class SemanticVersionJsonConverter : JsonConverter<SemanticVersion> internal sealed class SemanticVersionJsonConverter : JsonConverter<SemanticVersion>
{ {
@ -37,5 +37,33 @@ public readonly partial record struct SemanticVersion
{ {
writer.WriteStringValue(value.ToString("f", null)); 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));
}
} }
} }

View file

@ -6,7 +6,7 @@ using System.Globalization;
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVersion> public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
{ {
#region IParsable #region IParsable
@ -85,6 +85,9 @@ public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVe
// components = 1: wildcard at minor // components = 1: wildcard at minor
// components = 2: wildcard at patch // components = 2: wildcard at patch
// components = 3: no wildcards // components = 3: no wildcards
private static readonly System.Buffers.SearchValues<char> ValidIdentifierChars =
System.Buffers.SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-");
internal static bool TryParsePartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components) internal static bool TryParsePartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components)
{ {
version = default; version = default;
@ -146,10 +149,76 @@ public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVe
return false; 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; 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); version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata);
return true; return true;
} }
private static bool IsValidIdentifiers(ReadOnlySpan<char> 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;
}
} }

View file

@ -6,7 +6,7 @@ namespace Geekeey.SemVer;
/// <summary> /// <summary>
/// Represents a semantic version that adheres to the Semantic Versioning 2.0.0 specification. /// Represents a semantic version that adheres to the Semantic Versioning 2.0.0 specification.
/// </summary> /// </summary>
public readonly partial record struct SemanticVersion public readonly partial struct SemanticVersion : IEquatable<SemanticVersion>
{ {
/// <summary> /// <summary>
/// Converts a <see cref="Version"/> into the equivalent semantic version. /// Converts a <see cref="Version"/> 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. /// details. It does not affect the precedence of the version.
/// </summary> /// </summary>
public string? Metadata { get; } public string? Metadata { get; }
/// <inheritdoc />
public bool Equals(SemanticVersion other)
{
return SemanticVersionComparer.Priority.Equals(this, other);
}
/// <inheritdoc />
public override bool Equals(object? obj)
{
return obj is SemanticVersion other && Equals(other);
}
/// <inheritdoc />
public override int GetHashCode()
{
return SemanticVersionComparer.Priority.GetHashCode(this);
}
/// <summary>
/// Determines whether two versions have equal precedence.
/// </summary>
public static bool operator ==(SemanticVersion left, SemanticVersion right)
{
return left.Equals(right);
}
/// <summary>
/// Determines whether two versions differ in precedence.
/// </summary>
public static bool operator !=(SemanticVersion left, SemanticVersion right)
{
return !left.Equals(right);
}
} }

View file

@ -5,7 +5,7 @@ using System.Runtime.CompilerServices;
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersionRange : ISpanFormattable public readonly partial struct SemanticVersionRange : ISpanFormattable
{ {
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override string ToString()
@ -85,12 +85,29 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
if (format is "ns") 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 (!TryFormatSimpleNpm(ref buf, sets[i]))
{
if (buf.Written == writtenBefore)
{
if (!TryFormatNormalNpm(ref buf, sets[i]))
{ {
return false; return false;
} }
} }
else else
{
return false;
}
}
}
else
{ {
if (!TryFormatNormalNpm(ref buf, sets[i])) if (!TryFormatNormalNpm(ref buf, sets[i]))
{ {

View file

@ -7,7 +7,7 @@ using System.Text.Json.Serialization;
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
[JsonConverter(typeof(SemanticVersionRangeJsonConverter))] [JsonConverter(typeof(SemanticVersionRangeJsonConverter))]
public readonly partial record struct SemanticVersionRange public readonly partial struct SemanticVersionRange
{ {
internal sealed class SemanticVersionRangeJsonConverter : JsonConverter<SemanticVersionRange> internal sealed class SemanticVersionRangeJsonConverter : JsonConverter<SemanticVersionRange>
{ {
@ -37,5 +37,33 @@ public readonly partial record struct SemanticVersionRange
{ {
writer.WriteStringValue(value.ToString()); 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());
}
} }
} }

View file

@ -5,7 +5,7 @@ using System.Diagnostics.CodeAnalysis;
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersionRange : ISpanParsable<SemanticVersionRange> public readonly partial struct SemanticVersionRange : ISpanParsable<SemanticVersionRange>
{ {
#region IParsable #region IParsable

View file

@ -1,13 +1,15 @@
// Copyright (c) The Geekeey Authors // Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
using System;
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
/// <summary> /// <summary>
/// Represents a semantic version range, which is a set of version constraints /// Represents a semantic version range, which is a set of version constraints
/// used to match specific semantic versions based on defined ranges or patterns. /// used to match specific semantic versions based on defined ranges or patterns.
/// </summary> /// </summary>
public readonly partial record struct SemanticVersionRange public readonly partial struct SemanticVersionRange : IEquatable<SemanticVersionRange>
{ {
// OR of AND-groups. null == empty range (matches nothing). // OR of AND-groups. null == empty range (matches nothing).
private readonly ConstraintSet[]? _sets; private readonly ConstraintSet[]? _sets;
@ -31,6 +33,86 @@ public readonly partial record struct SemanticVersionRange
return _sets.Any(set => set.Includes(version)); return _sets.Any(set => set.Includes(version));
} }
/// <inheritdoc />
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;
}
/// <inheritdoc />
public override bool Equals(object? obj)
=> obj is SemanticVersionRange other && Equals(other);
/// <inheritdoc />
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();
}
/// <summary>
/// Determines whether two semantic version ranges are equal.
/// </summary>
public static bool operator ==(SemanticVersionRange left, SemanticVersionRange right)
=> left.Equals(right);
/// <summary>
/// Determines whether two semantic version ranges differ.
/// </summary>
public static bool operator !=(SemanticVersionRange left, SemanticVersionRange right)
=> !left.Equals(right);
} }
internal enum Comparison { Eq, Neq, Lt, Lte, Gt, Gte } internal enum Comparison { Eq, Neq, Lt, Lte, Gt, Gte }
@ -50,8 +132,10 @@ internal readonly struct Constraint
{ {
return Operation switch return Operation switch
{ {
Comparison.Eq => v == Version, // Build metadata does not affect precedence (SemVer 2.0.0 §10), so exact
Comparison.Neq => v != Version, // 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.Lt => v < Version,
Comparison.Lte => v <= Version, Comparison.Lte => v <= Version,
Comparison.Gt => v > Version, Comparison.Gt => v > Version,