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
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
namespace Geekeey.SemVer.Tests;
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");
}
[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]
[Arguments("[1.2.3]", "1.2.3", true)]
[Arguments("[1.2.3]", "1.2.4", false)]

View file

@ -78,6 +78,10 @@ public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVe
#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)
{
version = default;

View file

@ -106,92 +106,85 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
#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 => "!=",
ComparatorOp.Lt => "<",
ComparatorOp.Lte => "<=",
ComparatorOp.Gt => ">",
ComparatorOp.Gte => ">=",
_ => []
};
return buf.TryWrite('*');
}
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 buf.TryWrite(c.Version);
}
private static bool TryFormatSimpleNpm(ref SpanBuffer buf, ComparatorGroup group)
{
var c = group.Comparators;
var checkpoint = buf.Written;
if (c.Length is 0)
if (group is { Lower: { Op: ComparatorOp.Gte or ComparatorOp.Gt, Version: var lo }, Upper: { Op: ComparatorOp.Lt or ComparatorOp.Lte, Version: var hi } upper })
{
return buf.TryWrite('*');
}
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 (upper.Op is ComparatorOp.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } })
{
if ((lo.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))
if (lo is { Major: > 0 } && hi.Major == lo.Major + 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 &&
hi is { Patch: 0, Prerelease: not { Length: > 0 } } &&
hi.Major == lo.Major &&
hi.Minor == lo.Minor + 1)
if (upper.Op is ComparatorOp.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } })
{
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)
{
var comps = group.Comparators;
if (comps.Length is 0)
if (group.Comparators.Length is 0)
{
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;
}
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;
}
@ -204,8 +197,7 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable
{
if (group.Comparators is [{ Op: ComparatorOp.Eq, Version: var version }])
{
var exactCheckpoint = buf.Written;
return (buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']')) || buf.Reset(exactCheckpoint);
return buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']');
}
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);
}
var checkpoint = buf.Written;
return (buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') &&
(!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) &&
buf.TryWrite(',') &&
(!group.Upper.HasValue || buf.TryWrite(group.Upper.Value.Version)) &&
buf.TryWrite(group.Upper?.Op == ComparatorOp.Lte ? ']' : ')')) ||
buf.Reset(checkpoint);
return buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') &&
(!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) &&
buf.TryWrite(',') &&
(!group.Upper.HasValue || buf.TryWrite(group.Upper.Value.Version)) &&
buf.TryWrite(group.Upper?.Op == ComparatorOp.Lte ? ']' : ')');
}
}

View file

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

View file

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