diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index d48cf27..be73651 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -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)] diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs index f53dbcb..bc9b932 100644 --- a/src/semver/SemanticVersion.Parsing.cs +++ b/src/semver/SemanticVersion.Parsing.cs @@ -78,6 +78,10 @@ public readonly partial record struct SemanticVersion : ISpanParsable s, out SemanticVersion version, out int components) { version = default; diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index b083ee4..e9bcfa1 100644 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -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 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.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 ? ']' : ')'); } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs index ce8cf74..8c63358 100644 --- a/src/semver/SemanticVersionRange.Parsing.cs +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -145,6 +145,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out ComparatorGroup group) { group = default; + if (s.IsEmpty) { return false; @@ -173,7 +174,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable= 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= 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 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 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 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 StripOp(ReadOnlySpan s, out ComparatorOp op) { op = ComparatorOp.Eq; @@ -419,150 +389,41 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable token, List comparators) + private static bool TryExpandWildcardComparators(ReadOnlySpan s, List 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 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 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 s, out SemanticVersionRange result) { result = default; @@ -628,59 +489,58 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable 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(); - 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]); diff --git a/src/semver/SpanBuffer.cs b/src/semver/SpanBuffer.cs index 8c2c3fa..e15f851 100644 --- a/src/semver/SpanBuffer.cs +++ b/src/semver/SpanBuffer.cs @@ -40,11 +40,4 @@ internal ref struct SpanBuffer(Span 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; - } } \ No newline at end of file