From 8c93cf18a0a8b76885cd912aefcde29008ad09da Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Wed, 20 May 2026 22:39:41 +0200 Subject: [PATCH] wip --- src/semver.tests/SemanticVersionRangeTests.cs | 15 +- src/semver/SemanticVersionRange.Formatting.cs | 54 +++--- src/semver/SemanticVersionRange.Parsing.cs | 174 +++++++++--------- src/semver/SemanticVersionRange.cs | 9 + 4 files changed, 137 insertions(+), 115 deletions(-) diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index be73651..ac64db0 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -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.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) { var r = SemanticVersionRange.Parse(range); @@ -163,7 +166,7 @@ internal sealed class SemanticVersionRangeTests } [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.4", 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); } + [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(); + } + [Test] 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,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.0.0 !=2.0.0", "m", ">=1.0.0 !=2.0.0")] [Arguments("*", "m", "(,)")] [Arguments("(,)", "ns", "*")] public async Task I_can_convert_range_formats(string range, string format, string expected) diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index e9bcfa1..7923a72 100644 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -123,32 +123,39 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable 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 } }) { - if (upper.Op is ComparatorOp.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } }) + 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 (lo is { Major: > 0 } && hi.Major == lo.Major + 1) { - if (lo is { Major: > 0 } && hi.Major == lo.Major + 1) - { - 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); - } + return buf.TryWrite('^') && buf.TryWrite(lo); } - if (upper.Op is ComparatorOp.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } }) + if (lo is { Major: 0, Minor: > 0 } && hi is { Major: 0 } && hi.Minor == lo.Minor + 1) { - if (hi.Major == lo.Major && hi.Minor == lo.Minor + 1) - { - return buf.TryWrite('~') && buf.TryWrite(lo); - } + 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 is ComparatorOp.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } }) + { + if (hi.Major == lo.Major && hi.Minor == lo.Minor + 1) + { + return buf.TryWrite('~') && buf.TryWrite(lo); } } @@ -200,11 +207,6 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable 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 ? '[' : '(') && (!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) && buf.TryWrite(',') && diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs index 8c63358..cd874de 100644 --- a/src/semver/SemanticVersionRange.Parsing.cs +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -206,24 +206,21 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out ComparatorGroup group) { group = default; - // Look for " - " (space-hyphen-space) - for (var i = 1; i < s.Length - 1; i++) + for (var i = 0; i < s.Length; i++) { - if (s[i] == '-' && s[i - 1] == ' ' && i + 1 < s.Length && s[i + 1] == ' ') + if (s[i..] is [' ', '-', ' ', ..]) { - var lo = s[..(i - 1)].Trim(); - var hi = s[(i + 2)..].Trim(); - if (!SemanticVersion.TryParse(lo, null, out var loV)) + if (!SemanticVersion.TryParse(s[..(i + 0)].Trim(), null, out var lo)) { return false; } - if (!SemanticVersion.TryParse(hi, null, out var hiV)) + if (!SemanticVersion.TryParse(s[(i + 3)..].Trim(), null, out var hi)) { 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; } } @@ -276,6 +273,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable token, out Comparator result) { result = default; - token = token.Trim(); - if (token.IsEmpty) - { - return false; - } - // Parse operator - ComparatorOp op; - int opLen; - if (token.Length >= 2 && token[0] == '!' && token[1] == '=') + if (!TryParseComparatorPrefix(token.Trim(), out var op, out token)) { - op = ComparatorOp.Neq; - 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; + if (!SemanticVersion.TryParse(token, null, out var version)) + { + return false; + } + + result = new Comparator(op, version); + return true; } else { - switch (token[0]) + if (!SemanticVersion.TryParse(token, null, out var version)) { - 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; + return false; } - } - var vStr = token[opLen..]; - if (!SemanticVersion.TryParse(vStr, null, out var version)) + result = new Comparator(op, version); + return true; + } + } + + private static bool TryParseComparatorPrefix(ReadOnlySpan s, out ComparatorOp op, out ReadOnlySpan remainder) + { + if (s.IsEmpty) { + op = default; + remainder = default; return false; } - result = new Comparator(op, version); - return true; - } - - private static ReadOnlySpan StripOp(ReadOnlySpan s, out ComparatorOp op) - { - op = ComparatorOp.Eq; - var i = 0; - if (s.Length >= 2) + switch (s) { - if (s[0] == '>' && s[1] == '=') - { + case ['!', '=', ..]: + op = ComparatorOp.Neq; + remainder = s[2..]; + return true; + case ['>', '=', ..]: op = ComparatorOp.Gte; - return s[2..]; - } - - if (s[0] == '<' && s[1] == '=') - { + remainder = s[2..]; + return true; + case ['<', '=', ..]: 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 s, List comparators) { - s = StripOp(s.Trim(), out var op); - - if (op is not ComparatorOp.Eq) + s = s.Trim(); + if (s is ['*' or 'x' or 'X']) { - 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)) @@ -405,7 +385,8 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable +/// Represents a semantic version range, which is a set of version constraints +/// used to match specific semantic versions based on defined ranges or patterns. +/// public readonly partial record struct SemanticVersionRange { // OR of AND-groups. null == empty range (matches nothing). @@ -13,6 +17,11 @@ public readonly partial record struct SemanticVersionRange _groups = groups; } + /// + /// Determines whether the specified version is contained in the range. + /// + /// The version to check. + /// true if the version is contained in the range, otherwise false. public bool Contains(SemanticVersion version) { if (_groups is null || _groups.Length == 0)