wip
Some checks failed
default / dotnet-default-workflow (push) Failing after 1m36s

This commit is contained in:
Louis Seubert 2026-05-20 22:32:32 +02:00
commit 4eaa3c6d22
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
5 changed files with 116 additions and 259 deletions

View file

@ -1,8 +1,6 @@
// 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;
namespace Geekeey.SemVer.Tests; namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionRangeTests internal sealed class SemanticVersionRangeTests
@ -119,6 +117,19 @@ internal sealed class SemanticVersionRangeTests
await Assert.That(range.ToString("n", null)).IsEqualTo(">=1.0.0 !=2.0.0"); await Assert.That(range.ToString("n", null)).IsEqualTo(">=1.0.0 !=2.0.0");
} }
[Test]
[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")]
public async Task I_can_handle_ranges_with_wildcard(string range, string inside, string outside, string expected)
{
var r = SemanticVersionRange.Parse(range);
await Assert.That(r.Contains(SemanticVersion.Parse(inside))).IsTrue();
await Assert.That(r.Contains(SemanticVersion.Parse(outside))).IsFalse();
await Assert.That(r.ToString("n", null)).IsEqualTo(expected);
}
[Test] [Test]
[Arguments("[1.2.3]", "1.2.3", true)] [Arguments("[1.2.3]", "1.2.3", true)]
[Arguments("[1.2.3]", "1.2.4", false)] [Arguments("[1.2.3]", "1.2.4", false)]

View file

@ -78,6 +78,10 @@ public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVe
#endregion #endregion
// components = 0: wildcard at major
// components = 1: wildcard at minor
// components = 2: wildcard at patch
// components = 3: no wildcards
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;

View file

@ -106,92 +106,85 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
#endregion #endregion
private static bool TryWriteComparator(ref SpanBuffer buf, Comparator c) private static bool TryFormatSimpleNpm(ref SpanBuffer buf, ComparatorGroup group)
{ {
ReadOnlySpan<char> op = c.Op switch if (group.Comparators is [])
{ {
ComparatorOp.Neq => "!=", return buf.TryWrite('*');
ComparatorOp.Lt => "<", }
ComparatorOp.Lte => "<=",
ComparatorOp.Gt => ">",
ComparatorOp.Gte => ">=",
_ => []
};
if (!op.IsEmpty && !buf.TryWrite(op)) if (group.Comparators is [{ Op: ComparatorOp.Eq, Version: var version }])
{
return buf.TryWrite(version);
}
if (group.Comparators.Length is not 2)
{ {
return false; return false;
} }
return buf.TryWrite(c.Version); if (group is { Lower: { Op: ComparatorOp.Gte or ComparatorOp.Gt, Version: var lo }, Upper: { Op: ComparatorOp.Lt or ComparatorOp.Lte, Version: var hi } upper })
}
private static bool TryFormatSimpleNpm(ref SpanBuffer buf, ComparatorGroup group)
{
var c = group.Comparators;
var checkpoint = buf.Written;
if (c.Length is 0)
{ {
return buf.TryWrite('*'); if (upper.Op is ComparatorOp.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } })
}
if (c is [{ Op: ComparatorOp.Eq }])
{
return buf.TryWrite(c[0].Version) || buf.Reset(checkpoint);
}
if (c.Length is not 2)
{
return buf.Reset(checkpoint);
}
var lower = group.Lower!.Value;
var upper = group.Upper!.Value;
if (lower.Op is ComparatorOp.Gte or ComparatorOp.Gt && upper.Op is ComparatorOp.Lt or ComparatorOp.Lte)
{
var lo = lower.Version;
var hi = upper.Version;
if (upper.Op == ComparatorOp.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } })
{ {
if ((lo.Major > 0 && hi.Major == lo.Major + 1) || if (lo is { Major: > 0 } && hi.Major == lo.Major + 1)
(lo is { Major: 0, Minor: > 0 } && hi.Major == 0 && hi.Minor == lo.Minor + 1) ||
(lo is { Major: 0, Minor: 0 } && hi is { Major: 0, Minor: 0 } && hi.Patch == lo.Patch + 1))
{ {
return (buf.TryWrite('^') && buf.TryWrite(lo)) || buf.Reset(checkpoint); return buf.TryWrite('^') && buf.TryWrite(lo);
}
if (lo is { Major: 0, Minor: > 0 } && hi is { Major: 0 } && hi.Minor == lo.Minor + 1)
{
return buf.TryWrite('^') && buf.TryWrite(lo);
}
if (lo is { Major: 0, Minor: 0 } && hi is { Major: 0, Minor: 0 } && hi.Patch == lo.Patch + 1)
{
return buf.TryWrite('^') && buf.TryWrite(lo);
} }
} }
if (upper.Op == ComparatorOp.Lt && if (upper.Op is ComparatorOp.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } })
hi is { Patch: 0, Prerelease: not { Length: > 0 } } &&
hi.Major == lo.Major &&
hi.Minor == lo.Minor + 1)
{ {
return (buf.TryWrite('~') && buf.TryWrite(lo)) || buf.Reset(checkpoint); if (hi.Major == lo.Major && hi.Minor == lo.Minor + 1)
{
return buf.TryWrite('~') && buf.TryWrite(lo);
}
} }
} }
return buf.Reset(checkpoint); return false;
} }
private static bool TryFormatNormalNpm(ref SpanBuffer buf, ComparatorGroup group) private static bool TryFormatNormalNpm(ref SpanBuffer buf, ComparatorGroup group)
{ {
var comps = group.Comparators; if (group.Comparators.Length is 0)
if (comps.Length is 0)
{ {
return buf.TryWrite('*'); return buf.TryWrite('*');
} }
for (var j = 0; j < comps.Length; j++) for (var i = 0; i < group.Comparators.Length; i++)
{ {
if (j > 0 && !buf.TryWrite(' ')) if (i > 0 && !buf.TryWrite(' '))
{ {
return false; return false;
} }
if (!TryWriteComparator(ref buf, comps[j])) var @operator = group.Comparators[i].Op switch
{
ComparatorOp.Neq => "!=",
ComparatorOp.Lt => "<",
ComparatorOp.Lte => "<=",
ComparatorOp.Gt => ">",
ComparatorOp.Gte => ">=",
_ => ReadOnlySpan<char>.Empty,
};
if (!@operator.IsEmpty && !buf.TryWrite(@operator))
{
return false;
}
if (!buf.TryWrite(group.Comparators[i].Version))
{ {
return false; return false;
} }
@ -204,8 +197,7 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
{ {
if (group.Comparators is [{ Op: ComparatorOp.Eq, Version: var version }]) if (group.Comparators is [{ Op: ComparatorOp.Eq, Version: var version }])
{ {
var exactCheckpoint = buf.Written; return buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']');
return (buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']')) || buf.Reset(exactCheckpoint);
} }
if (group.Comparators.Any(c => c.Op is ComparatorOp.Neq) || group.Comparators.Length > 2) if (group.Comparators.Any(c => c.Op is ComparatorOp.Neq) || group.Comparators.Length > 2)
@ -213,13 +205,10 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
return TryFormatNormalNpm(ref buf, group); return TryFormatNormalNpm(ref buf, group);
} }
var checkpoint = buf.Written; return buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') &&
(!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) &&
return (buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') && buf.TryWrite(',') &&
(!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) && (!group.Upper.HasValue || buf.TryWrite(group.Upper.Value.Version)) &&
buf.TryWrite(',') && buf.TryWrite(group.Upper?.Op == ComparatorOp.Lte ? ']' : ')');
(!group.Upper.HasValue || buf.TryWrite(group.Upper.Value.Version)) &&
buf.TryWrite(group.Upper?.Op == ComparatorOp.Lte ? ']' : ')')) ||
buf.Reset(checkpoint);
} }
} }

View file

@ -145,6 +145,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
private static bool TryParseNpmGroup(ReadOnlySpan<char> s, out ComparatorGroup group) private static bool TryParseNpmGroup(ReadOnlySpan<char> s, out ComparatorGroup group)
{ {
group = default; group = default;
if (s.IsEmpty) if (s.IsEmpty)
{ {
return false; return false;
@ -173,7 +174,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
var token = end >= 0 ? s[..end] : s; var token = end >= 0 ? s[..end] : s;
tokens++; tokens++;
if (IsWildcardToken(token)) if (token.ContainsAny("*xX"))
{ {
if (!TryExpandWildcardComparators(token, comparators)) if (!TryExpandWildcardComparators(token, comparators))
{ {
@ -193,7 +194,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
s = end >= 0 ? s[(end + 1)..] : []; s = end >= 0 ? s[(end + 1)..] : [];
} }
if (comparators.Count == 0 && tokens == 0) if (comparators.Count is 0 && tokens is 0)
{ {
return false; return false;
} }
@ -233,43 +234,32 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
private static bool TryParseCaretRange(ReadOnlySpan<char> s, out ComparatorGroup group) private static bool TryParseCaretRange(ReadOnlySpan<char> s, out ComparatorGroup group)
{ {
group = default; group = default;
if (s.IsEmpty || s[0] is not '^') if (s.IsEmpty || s[0] is not '^')
{ {
return false; return false;
} }
s = s[1..]; s = s[1..];
// if (!TryParseWildcardVersion(s, out var version, out var minorWild, out var patchWild))
// { if (!SemanticVersion.TryParsePartially(s, out var lo, out _))
// return false;
// }
if (!SemanticVersion.TryParsePartially(s, out var version, out var comp))
{ {
return false; return false;
} }
var lo = comp is 0
? new SemanticVersion(version.Major, 0, 0)
: comp is 1
? new SemanticVersion(version.Major, version.Minor, 0)
: version;
SemanticVersion hi; SemanticVersion hi;
if (version.Major > 0) if (lo.Major > 0)
{ {
hi = new SemanticVersion(version.Major + 1, 0, 0); hi = new SemanticVersion(lo.Major + 1, 0, 0);
} }
else if (comp is not 1 && version.Minor > 0) else if (lo.Minor > 0)
{ {
hi = new SemanticVersion(0, version.Minor + 1, 0); hi = new SemanticVersion(0, lo.Minor + 1, 0);
}
else if (comp is not 2 and not 1)
{
hi = new SemanticVersion(0, 0, version.Patch + 1);
} }
else else
{ {
hi = new SemanticVersion(0, 1, 0); hi = new SemanticVersion(0, 0, lo.Patch + 1);
} }
group = new ComparatorGroup([new Comparator(ComparatorOp.Gte, lo), new Comparator(ComparatorOp.Lt, hi)]); group = new ComparatorGroup([new Comparator(ComparatorOp.Gte, lo), new Comparator(ComparatorOp.Lt, hi)]);
@ -279,21 +269,21 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
private static bool TryParseTildeRange(ReadOnlySpan<char> s, out ComparatorGroup group) private static bool TryParseTildeRange(ReadOnlySpan<char> s, out ComparatorGroup group)
{ {
group = default; group = default;
if (s.IsEmpty || s[0] is not '~') if (s.IsEmpty || s[0] is not '~')
{ {
return false; return false;
} }
s = s[1..]; s = s[1..];
if (!SemanticVersion.TryParsePartially(s, out var lo, out var comp)) if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard))
{ {
return false; return false;
} }
SemanticVersion hi; SemanticVersion hi;
if (comp is 1) if (wildcard is 1)
{ {
hi = new SemanticVersion(lo.Major + 1, 0, 0); hi = new SemanticVersion(lo.Major + 1, 0, 0);
} }
@ -366,26 +356,6 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
return true; return true;
} }
private static bool IsWildcardToken(ReadOnlySpan<char> s)
{
// Skip operator chars
var i = 0;
while (i < s.Length && (s[i] is '>' or '<' or '=' or '~' or '^'))
{
i++;
}
for (; i < s.Length; i++)
{
if (s[i] is '*' or 'x' or 'X')
{
return true;
}
}
return false;
}
private static ReadOnlySpan<char> StripOp(ReadOnlySpan<char> s, out ComparatorOp op) private static ReadOnlySpan<char> StripOp(ReadOnlySpan<char> s, out ComparatorOp op)
{ {
op = ComparatorOp.Eq; op = ComparatorOp.Eq;
@ -419,150 +389,41 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
return s[i..]; return s[i..];
} }
private static bool TryExpandWildcardComparators(ReadOnlySpan<char> token, List<Comparator> comparators) private static bool TryExpandWildcardComparators(ReadOnlySpan<char> s, List<Comparator> comparators)
{ {
var s = StripOp(token.Trim(), out var op); s = StripOp(s.Trim(), out var op);
if (op is not ComparatorOp.Eq) if (op is not ComparatorOp.Eq)
{ {
return false; return false;
} }
if (!TryParseWildcardVersion(s, out var version, out var minorWild, out var patchWild)) if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard))
{ {
return false; return false;
} }
if (!minorWild && !patchWild)
{
comparators.Add(new Comparator(ComparatorOp.Eq, version));
return true;
}
if (s is ['*' or 'x' or 'X']) if (s is ['*' or 'x' or 'X'])
{ {
return true; return true;
} }
comparators.Add(new Comparator(ComparatorOp.Gte, new SemanticVersion(version.Major, version.Minor, version.Patch))); SemanticVersion hi;
var upper = minorWild
? new SemanticVersion(version.Major + 1, 0, 0) if (wildcard is 1)
: new SemanticVersion(version.Major, version.Minor + 1, 0); {
comparators.Add(new Comparator(ComparatorOp.Lt, upper)); hi = new SemanticVersion(lo.Major + 1, 0, 0);
}
else
{
hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0);
}
comparators.Add(new Comparator(ComparatorOp.Gte, lo));
comparators.Add(new Comparator(ComparatorOp.Lt, hi));
return true; return true;
} }
private static bool TryParseWildcardVersion(ReadOnlySpan<char> s, out SemanticVersion version, out bool minorWild, out bool patchWild)
{
version = default;
minorWild = patchWild = false;
if (s.IsEmpty)
{
return false;
}
// Handle bare * or x/X
if (s is ['*' or 'x' or 'X'])
{
minorWild = patchWild = true;
version = new SemanticVersion(0, 0, 0);
return true;
}
ulong major = 0, minor = 0, patch = 0;
var pos = 0;
if (!TryParseNonNegativeInt(s, ref pos, out major))
{
return false;
}
if (pos >= s.Length)
{
minorWild = patchWild = true;
version = new SemanticVersion(major, 0, 0);
return true;
}
if (s[pos] != '.')
{
return false;
}
pos++;
if (pos < s.Length && (s[pos] == '*' || s[pos] == 'x' || s[pos] == 'X'))
{
minorWild = patchWild = true;
pos++;
version = new SemanticVersion(major, 0, 0);
return pos == s.Length;
}
if (!TryParseNonNegativeInt(s, ref pos, out minor))
{
return false;
}
if (pos >= s.Length)
{
patchWild = true;
version = new SemanticVersion(major, minor, 0);
return true;
}
if (s[pos] != '.')
{
return false;
}
pos++;
if (pos < s.Length && (s[pos] == '*' || s[pos] == 'x' || s[pos] == 'X'))
{
patchWild = true;
pos++;
version = new SemanticVersion(major, minor, 0);
return pos == s.Length;
}
if (!TryParseNonNegativeInt(s, ref pos, out patch))
{
return false;
}
if (pos == s.Length)
{
version = new SemanticVersion(major, minor, patch);
return true;
}
return (s[pos] == '-' || s[pos] == '+') && SemanticVersion.TryParse(s, null, out version);
}
internal static bool TryParseNonNegativeInt(ReadOnlySpan<char> s, ref int pos, out ulong value)
{
value = 0;
var start = pos;
while (pos < s.Length && char.IsAsciiDigit(s[pos]))
{
pos++;
}
if (pos == start)
{
return false;
}
if (pos - start > 1 && s[start] == '0')
{
return false; // no leading zeros
}
return ulong.TryParse(s[start..pos], out value);
}
// ── Maven parsing ────────────────────────────────────────────────────────
private static bool TryParseMaven(ReadOnlySpan<char> s, out SemanticVersionRange result) private static bool TryParseMaven(ReadOnlySpan<char> s, out SemanticVersionRange result)
{ {
result = default; result = default;
@ -628,59 +489,58 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable<Seman
private static bool TryParseMavenGroup(ReadOnlySpan<char> s, out ComparatorGroup group) private static bool TryParseMavenGroup(ReadOnlySpan<char> s, out ComparatorGroup group)
{ {
group = default; group = default;
var lInclusive = s[0] == '[';
var rInclusive = s[^1] == ']';
var inner = s[1..^1]; var loInclusive = s[0] is '[';
var commaIdx = inner.IndexOf(','); var hiInclusive = s[^1] is ']';
if (commaIdx < 0)
s = s[1..^1];
if (s.IndexOf(',') is not (>= 0 and var i))
{ {
if (!lInclusive || !rInclusive || !SemanticVersion.TryParsePartially(inner.Trim(), out var exact, out _)) if (!loInclusive || !hiInclusive)
{ {
return false; return false;
} }
group = new ComparatorGroup([new Comparator(ComparatorOp.Eq, exact)]); if (!SemanticVersion.TryParsePartially(s.Trim(), out var version, out _))
{
return false;
}
group = new ComparatorGroup([new Comparator(ComparatorOp.Eq, version)]);
return true; return true;
} }
var loStr = inner[..commaIdx].Trim();
var hiStr = inner[(commaIdx + 1)..].Trim();
var comps = new List<Comparator>(); var comps = new List<Comparator>();
if (!loStr.IsEmpty) if (s[..i].Trim() is { IsEmpty: false } loStr)
{ {
if (!SemanticVersion.TryParsePartially(loStr, out var lo, out _)) if (!SemanticVersion.TryParsePartially(loStr, out var lo, out _))
{ {
return false; return false;
} }
comps.Add(new Comparator(lInclusive ? ComparatorOp.Gte : ComparatorOp.Gt, lo)); comps.Add(new Comparator(loInclusive ? ComparatorOp.Gte : ComparatorOp.Gt, lo));
} }
if (!hiStr.IsEmpty) if (s[(i + 1)..].Trim() is { IsEmpty: false } hiStr)
{ {
if (!SemanticVersion.TryParsePartially(hiStr, out var hi, out _)) if (!SemanticVersion.TryParsePartially(hiStr, out var hi, out _))
{ {
return false; return false;
} }
comps.Add(new Comparator(rInclusive ? ComparatorOp.Lte : ComparatorOp.Lt, hi)); comps.Add(new Comparator(hiInclusive ? ComparatorOp.Lte : ComparatorOp.Lt, hi));
}
if (comps.Count == 0)
{
group = new ComparatorGroup([]);
return true;
} }
// Check for exact match [a,a] // Check for exact match [a,a]
if (comps is [{ Op: ComparatorOp.Gte }, { Op: ComparatorOp.Lte }] && if (comps is [{ Op: ComparatorOp.Gte, Version: var lhs }, { Op: ComparatorOp.Lte, Version: var rhs }])
comps[0].Version == comps[1].Version)
{ {
group = new ComparatorGroup([new Comparator(ComparatorOp.Eq, comps[0].Version)]); if (lhs == rhs)
return true; {
group = new ComparatorGroup([new Comparator(ComparatorOp.Eq, lhs)]);
return true;
}
} }
group = new ComparatorGroup([.. comps]); group = new ComparatorGroup([.. comps]);

View file

@ -40,11 +40,4 @@ internal ref struct SpanBuffer(Span<char> buffer)
Written += n; Written += n;
return true; return true;
} }
// Resets position to a saved checkpoint and returns false — useful in || chains.
public bool Reset(int checkpoint)
{
Written = checkpoint;
return false;
}
} }