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.
This commit is contained in:
Louis Seubert 2026-07-11 22:04:15 +02:00
commit f9531fe112
6 changed files with 99 additions and 4 deletions

View file

@ -17,6 +17,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
### Changed ### Changed
- `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules. - `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 ### Fixed

View file

@ -282,4 +282,14 @@ 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_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

@ -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()

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>
{ {

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 }