This commit is contained in:
Louis Seubert 2026-05-20 22:39:41 +02:00
commit 8c93cf18a0
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
4 changed files with 133 additions and 111 deletions

View file

@ -121,6 +121,9 @@ internal sealed class SemanticVersionRangeTests
[Arguments("^5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")] [Arguments("^5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")]
[Arguments("5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")] [Arguments("5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")]
[Arguments(">=5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")] [Arguments(">=5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")]
[Arguments(">5.*", "5.0.1", "5.0.0", ">5.0.0 <6.0.0")]
[Arguments("<=5.*", "5.9.9", "6.0.0", "<6.0.0")]
[Arguments("<5.*", "4.9.9", "5.0.0", "<5.0.0")]
public async Task I_can_handle_ranges_with_wildcard(string range, string inside, string outside, string expected) public async Task I_can_handle_ranges_with_wildcard(string range, string inside, string outside, string expected)
{ {
var r = SemanticVersionRange.Parse(range); var r = SemanticVersionRange.Parse(range);
@ -163,7 +166,7 @@ internal sealed class SemanticVersionRangeTests
} }
[Test] [Test]
[Arguments("^1.2.3", "1.2.3-alpha", true)] [Arguments("^1.2.3", "1.2.3-alpha", false)]
[Arguments("^1.2.3-alpha", "1.2.3-beta", true)] [Arguments("^1.2.3-alpha", "1.2.3-beta", true)]
[Arguments("^1.2.3-alpha", "1.2.4", true)] [Arguments("^1.2.3-alpha", "1.2.4", true)]
[Arguments("^1.2.3-alpha", "1.3.0-alpha", true)] [Arguments("^1.2.3-alpha", "1.3.0-alpha", true)]
@ -174,6 +177,15 @@ internal sealed class SemanticVersionRangeTests
await Assert.That(r.Contains(v)).IsEqualTo(expected); await Assert.That(r.Contains(v)).IsEqualTo(expected);
} }
[Test]
[Arguments(">=1.0.0 !2.0.0")]
[Arguments("1.2.3 - ")]
public async Task I_can_not_parse_invalid_ranges(string range)
{
await Assert.That(() => SemanticVersionRange.Parse(range))
.Throws<FormatException>();
}
[Test] [Test]
public async Task I_can_serialize_to_json() public async Task I_can_serialize_to_json()
{ {
@ -206,7 +218,6 @@ internal sealed class SemanticVersionRangeTests
[Arguments("[1.2.3,2.0.0)", "ns", "^1.2.3")] [Arguments("[1.2.3,2.0.0)", "ns", "^1.2.3")]
[Arguments("[1.2.3,1.3.0)", "ns", "~1.2.3")] [Arguments("[1.2.3,1.3.0)", "ns", "~1.2.3")]
[Arguments("[1.2,1.3],[1.5,)", "n", ">=1.2.0 <=1.3.0 || >=1.5.0")] [Arguments("[1.2,1.3],[1.5,)", "n", ">=1.2.0 <=1.3.0 || >=1.5.0")]
[Arguments(">=1.0.0 !=2.0.0", "m", ">=1.0.0 !=2.0.0")]
[Arguments("*", "m", "(,)")] [Arguments("*", "m", "(,)")]
[Arguments("(,)", "ns", "*")] [Arguments("(,)", "ns", "*")]
public async Task I_can_convert_range_formats(string range, string format, string expected) public async Task I_can_convert_range_formats(string range, string format, string expected)

View file

@ -123,8 +123,16 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
return false; return false;
} }
if (group is { Lower: { Op: ComparatorOp.Gte or ComparatorOp.Gt, Version: var lo }, Upper: { Op: ComparatorOp.Lt or ComparatorOp.Lte, Version: var hi } upper }) if (group is not { Lower: { Op: ComparatorOp.Gte or ComparatorOp.Gt, Version: var lo } })
{ {
return false;
}
if (group is not { Upper: { Op: ComparatorOp.Lt or ComparatorOp.Lte, Version: var hi } upper })
{
return false;
}
if (upper.Op is ComparatorOp.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } }) if (upper.Op is ComparatorOp.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } })
{ {
if (lo is { Major: > 0 } && hi.Major == lo.Major + 1) if (lo is { Major: > 0 } && hi.Major == lo.Major + 1)
@ -150,7 +158,6 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
return buf.TryWrite('~') && buf.TryWrite(lo); return buf.TryWrite('~') && buf.TryWrite(lo);
} }
} }
}
return false; return false;
} }
@ -200,11 +207,6 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
return buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']'); return buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']');
} }
if (group.Comparators.Any(c => c.Op is ComparatorOp.Neq) || group.Comparators.Length > 2)
{
return TryFormatNormalNpm(ref buf, group);
}
return buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') && return buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') &&
(!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) && (!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) &&
buf.TryWrite(',') && buf.TryWrite(',') &&

View file

@ -206,24 +206,21 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
private static bool TryParseHyphenRange(ReadOnlySpan<char> s, out ComparatorGroup group) private static bool TryParseHyphenRange(ReadOnlySpan<char> s, out ComparatorGroup group)
{ {
group = default; group = default;
// Look for " - " (space-hyphen-space) for (var i = 0; i < s.Length; i++)
for (var i = 1; i < s.Length - 1; i++)
{ {
if (s[i] == '-' && s[i - 1] == ' ' && i + 1 < s.Length && s[i + 1] == ' ') if (s[i..] is [' ', '-', ' ', ..])
{ {
var lo = s[..(i - 1)].Trim(); if (!SemanticVersion.TryParse(s[..(i + 0)].Trim(), null, out var lo))
var hi = s[(i + 2)..].Trim();
if (!SemanticVersion.TryParse(lo, null, out var loV))
{ {
return false; return false;
} }
if (!SemanticVersion.TryParse(hi, null, out var hiV)) if (!SemanticVersion.TryParse(s[(i + 3)..].Trim(), null, out var hi))
{ {
return false; return false;
} }
group = new ComparatorGroup([new Comparator(ComparatorOp.Gte, loV), new Comparator(ComparatorOp.Lte, hiV)]); group = new ComparatorGroup([new Comparator(ComparatorOp.Gte, lo), new Comparator(ComparatorOp.Lte, hi)]);
return true; return true;
} }
} }
@ -276,6 +273,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
} }
s = s[1..]; s = s[1..];
if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard)) if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard))
{ {
return false; return false;
@ -299,55 +297,10 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
private static bool TryParseComparator(ReadOnlySpan<char> token, out Comparator result) private static bool TryParseComparator(ReadOnlySpan<char> token, out Comparator result)
{ {
result = default; result = default;
token = token.Trim();
if (token.IsEmpty)
{
return false;
}
// Parse operator if (!TryParseComparatorPrefix(token.Trim(), out var op, out token))
ComparatorOp op;
int opLen;
if (token.Length >= 2 && token[0] == '!' && token[1] == '=')
{ {
op = ComparatorOp.Neq; if (!SemanticVersion.TryParse(token, null, out var version))
opLen = 2;
}
else if (token.Length >= 2 && token[0] == '>' && token[1] == '=')
{
op = ComparatorOp.Gte;
opLen = 2;
}
else if (token.Length >= 2 && token[0] == '<' && token[1] == '=')
{
op = ComparatorOp.Lte;
opLen = 2;
}
else
{
switch (token[0])
{
case '>':
op = ComparatorOp.Gt;
opLen = 1;
break;
case '<':
op = ComparatorOp.Lt;
opLen = 1;
break;
case '=':
op = ComparatorOp.Eq;
opLen = 1;
break;
default:
op = ComparatorOp.Eq;
opLen = 0;
break;
}
}
var vStr = token[opLen..];
if (!SemanticVersion.TryParse(vStr, null, out var version))
{ {
return false; return false;
} }
@ -355,47 +308,74 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
result = new Comparator(op, version); result = new Comparator(op, version);
return true; return true;
} }
else
{
if (!SemanticVersion.TryParse(token, null, out var version))
{
return false;
}
private static ReadOnlySpan<char> StripOp(ReadOnlySpan<char> s, out ComparatorOp op) result = new Comparator(op, version);
return true;
}
}
private static bool TryParseComparatorPrefix(ReadOnlySpan<char> s, out ComparatorOp op, out ReadOnlySpan<char> remainder)
{ {
op = ComparatorOp.Eq; if (s.IsEmpty)
var i = 0;
if (s.Length >= 2)
{ {
if (s[0] == '>' && s[1] == '=') op = default;
remainder = default;
return false;
}
switch (s)
{ {
case ['!', '=', ..]:
op = ComparatorOp.Neq;
remainder = s[2..];
return true;
case ['>', '=', ..]:
op = ComparatorOp.Gte; op = ComparatorOp.Gte;
return s[2..]; remainder = s[2..];
} return true;
case ['<', '=', ..]:
if (s[0] == '<' && s[1] == '=')
{
op = ComparatorOp.Lte; op = ComparatorOp.Lte;
return s[2..]; remainder = s[2..];
return true;
case ['>', ..]:
op = ComparatorOp.Gt;
remainder = s[1..];
return true;
case ['<', ..]:
op = ComparatorOp.Lt;
remainder = s[1..];
return true;
case ['=', ..]:
op = ComparatorOp.Eq;
remainder = s[1..];
return true;
default:
remainder = s;
op = default;
return false;
} }
} }
while (i < s.Length && (s[i] is '>' or '<' or '='))
{
op = s[i] switch
{
'>' => ComparatorOp.Gt,
'<' => ComparatorOp.Lt,
_ => ComparatorOp.Eq,
};
i++;
}
return s[i..];
}
private static bool TryExpandWildcardComparators(ReadOnlySpan<char> s, List<Comparator> comparators) private static bool TryExpandWildcardComparators(ReadOnlySpan<char> s, List<Comparator> comparators)
{ {
s = StripOp(s.Trim(), out var op); s = s.Trim();
if (s is ['*' or 'x' or 'X'])
if (op is not ComparatorOp.Eq)
{ {
return false; // bare wildcards only make sense when it is an exact version comparison.
return true;
}
var op = ComparatorOp.Eq;
if (TryParseComparatorPrefix(s, out var parsedOp, out var remainder))
{
op = parsedOp;
s = remainder;
} }
if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard)) if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard))
@ -405,7 +385,8 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
if (s is ['*' or 'x' or 'X']) if (s is ['*' or 'x' or 'X'])
{ {
return true; // bare wildcards only make sense when it is an exact version comparison.
return op == ComparatorOp.Eq;
} }
SemanticVersion hi; SemanticVersion hi;
@ -419,8 +400,27 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0); hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0);
} }
switch (op)
{
case ComparatorOp.Eq:
case ComparatorOp.Gte:
comparators.Add(new Comparator(ComparatorOp.Gte, lo)); comparators.Add(new Comparator(ComparatorOp.Gte, lo));
comparators.Add(new Comparator(ComparatorOp.Lt, hi)); comparators.Add(new Comparator(ComparatorOp.Lt, hi));
break;
case ComparatorOp.Gt:
comparators.Add(new Comparator(ComparatorOp.Gt, lo));
comparators.Add(new Comparator(ComparatorOp.Lt, hi));
break;
case ComparatorOp.Lte:
comparators.Add(new Comparator(ComparatorOp.Lt, hi));
break;
case ComparatorOp.Lt:
comparators.Add(new Comparator(ComparatorOp.Lt, lo));
break;
default:
return false;
}
return true; return true;
} }

View file

@ -3,6 +3,10 @@
namespace Geekeey.SemVer; namespace Geekeey.SemVer;
/// <summary>
/// Represents a semantic version range, which is a set of version constraints
/// used to match specific semantic versions based on defined ranges or patterns.
/// </summary>
public readonly partial record struct SemanticVersionRange public readonly partial record struct SemanticVersionRange
{ {
// OR of AND-groups. null == empty range (matches nothing). // OR of AND-groups. null == empty range (matches nothing).
@ -13,6 +17,11 @@ public readonly partial record struct SemanticVersionRange
_groups = groups; _groups = groups;
} }
/// <summary>
/// Determines whether the specified version is contained in the range.
/// </summary>
/// <param name="version">The version to check.</param>
/// <returns><c>true</c> if the version is contained in the range, otherwise <c>false</c>.</returns>
public bool Contains(SemanticVersion version) public bool Contains(SemanticVersion version)
{ {
if (_groups is null || _groups.Length == 0) if (_groups is null || _groups.Length == 0)