semver/src/semver/SemanticVersionRange.Parsing.cs
Louis Seubert b21bf0568e
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:09:37 +02:00

515 lines
11 KiB
C#

// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.SemVer;
public readonly partial struct SemanticVersionRange : ISpanParsable<SemanticVersionRange>
{
#region IParsable
/// <summary>
/// Parses a string into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="Parse(string, IFormatProvider)"/>
public static SemanticVersionRange Parse(string s)
{
return Parse(s.AsSpan(), null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(string s, IFormatProvider? provider)
{
return Parse(s.AsSpan(), provider);
}
/// <summary>
/// Tries to parse a string into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="TryParse(string, IFormatProvider, out SemanticVersionRange)"/>
public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s.AsSpan(), provider, out result);
}
#endregion
#region ISpanParsable
/// <summary>
/// Parses a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="Parse(ReadOnlySpan{char}, IFormatProvider)"/>
public static SemanticVersionRange Parse(ReadOnlySpan<char> s)
{
return Parse(s, null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
{
if (!TryParse(s, provider, out var result))
{
throw new FormatException($"The input string '{s}' was not in a correct format.");
}
return result;
}
/// <summary>
/// Tries to parse a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="TryParse(ReadOnlySpan{char}, IFormatProvider, out SemanticVersionRange)"/>
public static bool TryParse(ReadOnlySpan<char> s, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
result = default;
if (s.IsEmpty)
{
return false;
}
return s[0] is '[' or '(' ? TryParseMaven(s, out result) : TryParseNpm(s, out result);
}
#endregion
private static bool TryParseNpm(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
result = default;
var sets = new List<ConstraintSet>();
foreach (var range in new NpmSetGrouping(s))
{
if (!TryParseNpmSet(s[range].Trim(), out var set))
{
return false;
}
sets.Add(set);
}
result = new SemanticVersionRange([.. sets]);
return true;
}
private ref struct NpmSetGrouping
{
private readonly ReadOnlySpan<char> _span;
private int _currentStart;
private int _currentEnd;
public NpmSetGrouping(ReadOnlySpan<char> span)
{
_span = span;
_currentStart = 0;
_currentEnd = -1;
}
public readonly Range Current => _currentStart.._currentEnd;
public bool MoveNext()
{
_currentStart = _currentEnd is -1 ? 0 : _currentEnd + 2;
if (_currentStart >= _span.Length)
{
return false;
}
var index = _span[_currentStart..].IndexOf("||");
_currentEnd = index >= 0 ? _currentStart + index : _span.Length;
return true;
}
public readonly NpmSetGrouping GetEnumerator()
{
return this;
}
}
private static bool TryParseNpmSet(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
if (s.IsEmpty)
{
return false;
}
// Hyphen range: "1.0.0 - 2.0.0" OR Caret: "^x.y.z" OR Tilde: "~x.y.z"
if (TryParseHyphenRange(s, out set) || TryParseCaretRange(s, out set) || TryParseTildeRange(s, out set))
{
return true;
}
var comparators = new List<Constraint>();
foreach (var range in s.Split(' '))
{
if (s[range] is not { IsEmpty: false } segment)
{
continue;
}
if (!TryParseCompare(segment, comparators))
{
return false;
}
}
if (comparators.Count is 0)
{
return false;
}
set = new ConstraintSet([.. comparators]);
return true;
}
private static bool TryParseHyphenRange(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
for (var i = 0; i < s.Length; i++)
{
if (s[i..] is [' ', '-', ' ', ..])
{
if (!SemanticVersion.TryParse(s[..(i + 0)].Trim(), null, out var lo))
{
return false;
}
if (!SemanticVersion.TryParse(s[(i + 3)..].Trim(), null, out var hi))
{
return false;
}
set = new ConstraintSet([new Constraint(Comparison.Gte, lo), new Constraint(Comparison.Lte, hi)]);
return true;
}
}
return false;
}
private static bool TryParseCaretRange(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
if (s.IsEmpty || s[0] is not '^')
{
return false;
}
s = s[1..];
if (!SemanticVersion.TryParsePartially(s, out var lo, out _))
{
return false;
}
SemanticVersion hi;
if (lo.Major > 0)
{
hi = new SemanticVersion(lo.Major + 1, 0, 0);
}
else if (lo.Minor > 0)
{
hi = new SemanticVersion(0, lo.Minor + 1, 0);
}
else
{
hi = new SemanticVersion(0, 0, lo.Patch + 1);
}
set = new ConstraintSet([new Constraint(Comparison.Gte, lo), new Constraint(Comparison.Lt, hi)]);
return true;
}
private static bool TryParseTildeRange(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
if (s.IsEmpty || s[0] is not '~')
{
return false;
}
s = s[1..];
if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard))
{
return false;
}
SemanticVersion hi;
if (wildcard is 1)
{
hi = new SemanticVersion(lo.Major + 1, 0, 0);
}
else
{
hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0);
}
set = new ConstraintSet([new Constraint(Comparison.Gte, lo), new Constraint(Comparison.Lt, hi)]);
return true;
}
private static bool TryParseCompare(ReadOnlySpan<char> s, List<Constraint> constraints)
{
var op = Comparison.Eq;
if (TryParseComparatorPrefix(s, out var comparison, out var rest))
{
op = comparison;
s = rest;
}
if (!SemanticVersion.TryParsePartially(s, out var lo, out var components))
{
return false;
}
if (components is 0)
{
constraints.Add(new Constraint(Comparison.Gte, new SemanticVersion(0, 0, 0)));
return op is Comparison.Eq;
}
if (components is 3)
{
constraints.Add(new Constraint(op, lo));
return true;
}
SemanticVersion hi;
if (components is 1)
{
hi = new SemanticVersion(lo.Major + 1, 0, 0);
}
else
{
hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0);
}
switch (op)
{
case Comparison.Eq:
case Comparison.Gte:
constraints.Add(new Constraint(Comparison.Gte, lo));
constraints.Add(new Constraint(Comparison.Lt, hi));
return true;
case Comparison.Gt:
constraints.Add(new Constraint(Comparison.Gt, lo));
constraints.Add(new Constraint(Comparison.Lt, hi));
return true;
case Comparison.Lte:
constraints.Add(new Constraint(Comparison.Lt, hi));
return true;
case Comparison.Lt:
constraints.Add(new Constraint(Comparison.Lt, lo));
return true;
case Comparison.Neq:
default:
return false;
}
}
private static bool TryParseComparatorPrefix(ReadOnlySpan<char> s, out Comparison op, out ReadOnlySpan<char> remainder)
{
if (s.IsEmpty)
{
op = default;
remainder = default;
return false;
}
switch (s)
{
case ['!', '=', ..]:
op = Comparison.Neq;
remainder = s[2..];
return true;
case ['>', '=', ..]:
op = Comparison.Gte;
remainder = s[2..];
return true;
case ['<', '=', ..]:
op = Comparison.Lte;
remainder = s[2..];
return true;
case ['>', ..]:
op = Comparison.Gt;
remainder = s[1..];
return true;
case ['<', ..]:
op = Comparison.Lt;
remainder = s[1..];
return true;
case ['=', ..]:
op = Comparison.Eq;
remainder = s[1..];
return true;
default:
remainder = s;
op = default;
return false;
}
}
private static bool TryParseMaven(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
result = default;
var sets = new List<ConstraintSet>();
foreach (var range in new MavenSetGrouping(s))
{
if (!TryParseMavenSet(s[range].Trim(), out var set))
{
return false;
}
sets.Add(set);
}
result = new SemanticVersionRange([.. sets]);
return true;
}
private ref struct MavenSetGrouping
{
private readonly ReadOnlySpan<char> _span;
private int _currentStart;
private int _currentEnd;
public MavenSetGrouping(ReadOnlySpan<char> readOnlySpan)
{
_span = readOnlySpan;
_currentStart = 0;
_currentEnd = -1;
}
public readonly Range Current => _currentStart.._currentEnd;
public bool MoveNext()
{
_currentStart = _currentEnd is -1 ? 0 : _currentEnd + 1;
if (_currentStart >= _span.Length)
{
return false;
}
var depth = 0;
var i = _currentStart;
while (i < _span.Length)
{
var ch = _span[i];
if (ch is '[' or '(')
{
depth++;
}
else if (ch is ']' or ')')
{
depth--;
}
else if (ch is ',' && depth is 0)
{
_currentEnd = i;
return true;
}
i++;
}
_currentEnd = _span.Length;
return true;
}
public readonly MavenSetGrouping GetEnumerator()
{
return this;
}
}
private static bool TryParseMavenSet(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
var loInclusive = s[0] is '[';
var hiInclusive = s[^1] is ']';
s = s[1..^1];
if (s.IndexOf(',') is not (>= 0 and var i))
{
if (!loInclusive || !hiInclusive)
{
return false;
}
if (!SemanticVersion.TryParsePartially(s.Trim(), out var version, out _))
{
return false;
}
set = new ConstraintSet([new Constraint(Comparison.Eq, version)]);
return true;
}
var comps = new List<Constraint>();
if (s[..i].Trim() is { IsEmpty: false } loStr)
{
if (!SemanticVersion.TryParsePartially(loStr, out var lo, out _))
{
return false;
}
comps.Add(new Constraint(loInclusive ? Comparison.Gte : Comparison.Gt, lo));
}
if (s[(i + 1)..].Trim() is { IsEmpty: false } hiStr)
{
if (!SemanticVersion.TryParsePartially(hiStr, out var hi, out _))
{
return false;
}
comps.Add(new Constraint(hiInclusive ? Comparison.Lte : Comparison.Lt, hi));
}
// Check for exact match [a,a]
if (comps is [{ Operation: Comparison.Gte, Version: var lhs }, { Operation: Comparison.Lte, Version: var rhs }])
{
if (lhs == rhs)
{
set = new ConstraintSet([new Constraint(Comparison.Eq, lhs)]);
return true;
}
}
set = new ConstraintSet([.. comps]);
return true;
}
}