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.
515 lines
11 KiB
C#
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;
|
|
}
|
|
}
|