From f9fb920e071e42b56920c258bd37ad0009ed5a95 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 21 May 2026 20:42:05 +0200 Subject: [PATCH] wip --- src/semver.tests/SemanticVersionRangeTests.cs | 11 + src/semver.tests/SemanticVersionTests.cs | 2 +- src/semver/SemanticVersionRange.Formatting.cs | 72 ++-- src/semver/SemanticVersionRange.Parsing.cs | 337 ++++++++---------- src/semver/SemanticVersionRange.cs | 67 ++-- 5 files changed, 241 insertions(+), 248 deletions(-) diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index ac64db0..715efb1 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -81,6 +81,16 @@ internal sealed class SemanticVersionRangeTests await Assert.That(r.Contains(v)).IsEqualTo(expected); } + [Test] + public async Task I_can_parse_exact_version_ranges_via_public_api() + { + var range = SemanticVersionRange.Parse("=1.2.3"); + + await Assert.That(range.Contains(SemanticVersion.Parse("1.2.3"))).IsTrue(); + await Assert.That(range.Contains(SemanticVersion.Parse("1.2.4"))).IsFalse(); + await Assert.That(range.ToString("n", null)).IsEqualTo("1.2.3"); + } + [Test] [Arguments(">1.2.3 || <1.0.0", "1.2.4", true)] [Arguments(">1.2.3 || <1.0.0", "0.9.9", true)] @@ -180,6 +190,7 @@ internal sealed class SemanticVersionRangeTests [Test] [Arguments(">=1.0.0 !2.0.0")] [Arguments("1.2.3 - ")] + [Arguments("!=1.x")] public async Task I_can_not_parse_invalid_ranges(string range) { await Assert.That(() => SemanticVersionRange.Parse(range)) diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index 3494fd0..0749384 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -203,7 +203,7 @@ internal sealed class SemanticVersionTests var obj = new { Version = new SemanticVersion(1, 0, 0, "rc.1", "metadata") }; var json = JsonSerializer.Serialize(obj, RelaxedOptions); - await Assert.That(json).IsEqualTo("{\"Version\":\"1.0.0-rc.1+metadata\"}"); + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ "{\"Version\":\"1.0.0-rc.1+metadata\"}"); } [Test] diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index 7923a72..fa1775c 100644 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -57,18 +57,18 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable } var buf = new SpanBuffer(destination); - var groups = _groups ?? []; + var sets = _sets ?? []; if (format is "m") { - for (var i = 0; i < groups.Length; i++) + for (var i = 0; i < sets.Length; i++) { if (i > 0 && !buf.TryWrite(',')) { return false; } - if (!TryFormatMaven(ref buf, groups[i])) + if (!TryFormatMaven(ref buf, sets[i])) { return false; } @@ -76,7 +76,7 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable } else { - for (var i = 0; i < groups.Length; i++) + for (var i = 0; i < sets.Length; i++) { if (i > 0 && !buf.TryWrite(" || ")) { @@ -85,14 +85,14 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable if (format is "ns") { - if (!TryFormatSimpleNpm(ref buf, groups[i])) + if (!TryFormatSimpleNpm(ref buf, sets[i])) { return false; } } else { - if (!TryFormatNormalNpm(ref buf, groups[i])) + if (!TryFormatNormalNpm(ref buf, sets[i])) { return false; } @@ -106,34 +106,34 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable #endregion - private static bool TryFormatSimpleNpm(ref SpanBuffer buf, ComparatorGroup group) + private static bool TryFormatSimpleNpm(ref SpanBuffer buf, ConstraintSet set) { - if (group.Comparators is []) + if (IsAny(set)) { return buf.TryWrite('*'); } - if (group.Comparators is [{ Op: ComparatorOp.Eq, Version: var version }]) + if (set.Constraints is [{ Operation: Comparison.Eq, Version: var version }]) { return buf.TryWrite(version); } - if (group.Comparators.Length is not 2) + if (set.Constraints.Length is not 2) { return false; } - if (group is not { Lower: { Op: ComparatorOp.Gte or ComparatorOp.Gt, Version: var lo } }) + if (set is not { Lower: { Operation: Comparison.Gte or Comparison.Gt, Version: var lo } }) { return false; } - if (group is not { Upper: { Op: ComparatorOp.Lt or ComparatorOp.Lte, Version: var hi } upper }) + if (set is not { Upper: { Operation: Comparison.Lt or Comparison.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.Operation is Comparison.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } }) { if (lo is { Major: > 0 } && hi.Major == lo.Major + 1) { @@ -151,7 +151,7 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable } } - if (upper.Op is ComparatorOp.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } }) + if (upper.Operation is Comparison.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } }) { if (hi.Major == lo.Major && hi.Minor == lo.Minor + 1) { @@ -162,28 +162,28 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable return false; } - private static bool TryFormatNormalNpm(ref SpanBuffer buf, ComparatorGroup group) + private static bool TryFormatNormalNpm(ref SpanBuffer buf, ConstraintSet set) { - if (group.Comparators.Length is 0) + if (IsAny(set)) { return buf.TryWrite('*'); } - for (var i = 0; i < group.Comparators.Length; i++) + for (var i = 0; i < set.Constraints.Length; i++) { if (i > 0 && !buf.TryWrite(' ')) { return false; } - var @operator = group.Comparators[i].Op switch + var @operator = set.Constraints[i].Operation switch { - ComparatorOp.Neq => "!=", - ComparatorOp.Lt => "<", - ComparatorOp.Lte => "<=", - ComparatorOp.Gt => ">", - ComparatorOp.Gte => ">=", - _ => ReadOnlySpan.Empty, + Comparison.Neq => "!=", + Comparison.Lt => "<", + Comparison.Lte => "<=", + Comparison.Gt => ">", + Comparison.Gte => ">=", + Comparison.Eq or _ => ReadOnlySpan.Empty, }; if (!@operator.IsEmpty && !buf.TryWrite(@operator)) @@ -191,7 +191,7 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable return false; } - if (!buf.TryWrite(group.Comparators[i].Version)) + if (!buf.TryWrite(set.Constraints[i].Version)) { return false; } @@ -200,17 +200,27 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable return true; } - private static bool TryFormatMaven(ref SpanBuffer buf, ComparatorGroup group) + private static bool TryFormatMaven(ref SpanBuffer buf, ConstraintSet set) { - if (group.Comparators is [{ Op: ComparatorOp.Eq, Version: var version }]) + if (IsAny(set)) + { + return buf.TryWrite("(,)"); + } + + if (set.Constraints is [{ Operation: Comparison.Eq, Version: var version }]) { return buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']'); } - return buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') && - (!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) && + return buf.TryWrite(set.Lower?.Operation == Comparison.Gte ? '[' : '(') && + (!set.Lower.HasValue || buf.TryWrite(set.Lower.Value.Version)) && buf.TryWrite(',') && - (!group.Upper.HasValue || buf.TryWrite(group.Upper.Value.Version)) && - buf.TryWrite(group.Upper?.Op == ComparatorOp.Lte ? ']' : ')'); + (!set.Upper.HasValue || buf.TryWrite(set.Upper.Value.Version)) && + buf.TryWrite(set.Upper?.Operation == Comparison.Lte ? ']' : ')'); + } + + private static bool IsAny(ConstraintSet set) + { + return set.Constraints is [] or [{ Operation: Comparison.Gte, Version: { Major: 0, Minor: 0, Patch: 0 } }]; } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs index cd874de..10dff22 100644 --- a/src/semver/SemanticVersionRange.Parsing.cs +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -1,6 +1,7 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; namespace Geekeey.SemVer; @@ -90,29 +91,29 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out SemanticVersionRange result) { result = default; - var groups = new List(); + var sets = new List(); - foreach (var range in new NodeSetGrouping(s)) + foreach (var range in new NpmSetGrouping(s)) { - if (!TryParseNpmGroup(s[range].Trim(), out var group)) + if (!TryParseNpmSet(s[range].Trim(), out var set)) { return false; } - groups.Add(group); + sets.Add(set); } - result = new SemanticVersionRange([.. groups]); + result = new SemanticVersionRange([.. sets]); return true; } - private ref struct NodeSetGrouping + private ref struct NpmSetGrouping { private readonly ReadOnlySpan _span; private int _currentStart; private int _currentEnd; - public NodeSetGrouping(ReadOnlySpan span) + public NpmSetGrouping(ReadOnlySpan span) { _span = span; _currentStart = 0; @@ -136,15 +137,15 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out ComparatorGroup group) + private static bool TryParseNpmSet(ReadOnlySpan s, out ConstraintSet set) { - group = default; + set = default; if (s.IsEmpty) { @@ -152,60 +153,38 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable(); - var tokens = 0; - while (!s.IsEmpty) + var comparators = new List(); + foreach (var range in s.Split(' ')) { - // Skip spaces - s = s.TrimStart(); - if (s.IsEmpty) + if (s[range] is not { IsEmpty: false } segment) { - break; + continue; } - // Find end of this token (next space) - var end = s.IndexOf(' '); - var token = end >= 0 ? s[..end] : s; - tokens++; - - if (token.ContainsAny("*xX")) + if (!TryParseCompare(segment, comparators)) { - if (!TryExpandWildcardComparators(token, comparators)) - { - return false; - } + return false; } - else - { - if (!TryParseComparator(token, out var comp)) - { - return false; - } - - comparators.Add(comp); - } - - s = end >= 0 ? s[(end + 1)..] : []; } - if (comparators.Count is 0 && tokens is 0) + if (comparators.Count is 0) { return false; } - group = new ComparatorGroup([.. comparators]); + set = new ConstraintSet([.. comparators]); return true; } - private static bool TryParseHyphenRange(ReadOnlySpan s, out ComparatorGroup group) + private static bool TryParseHyphenRange(ReadOnlySpan s, out ConstraintSet set) { - group = default; + set = default; + for (var i = 0; i < s.Length; i++) { if (s[i..] is [' ', '-', ' ', ..]) @@ -220,7 +199,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out ComparatorGroup group) + private static bool TryParseCaretRange(ReadOnlySpan s, out ConstraintSet set) { - group = default; + set = default; if (s.IsEmpty || s[0] is not '^') { @@ -259,13 +238,13 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out ComparatorGroup group) + private static bool TryParseTildeRange(ReadOnlySpan s, out ConstraintSet set) { - group = default; + set = default; if (s.IsEmpty || s[0] is not '~') { @@ -290,37 +269,72 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable token, out Comparator result) + private static bool TryParseCompare(ReadOnlySpan s, List constraints) { - result = default; + var op = Comparison.Eq; - if (!TryParseComparatorPrefix(token.Trim(), out var op, out token)) + if (TryParseComparatorPrefix(s, out var comparison, out var rest)) { - if (!SemanticVersion.TryParse(token, null, out var version)) - { - return false; - } + op = comparison; + s = rest; + } - result = new Comparator(op, version); + if (!SemanticVersion.TryParsePartially(s, out var lo, out var components)) + { + return false; + } + + if (components is 0) + { + constraints.Add(new Constraint(Comparison.Gte, new SemanticVersion(0, 0, 0))); + return op is Comparison.Eq; + } + + if (components is 3) + { + constraints.Add(new Constraint(op, lo)); return true; } + + SemanticVersion hi; + + if (components is 1) + { + hi = new SemanticVersion(lo.Major + 1, 0, 0); + } else { - if (!SemanticVersion.TryParse(token, null, out var version)) - { - return false; - } + hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0); + } - result = new Comparator(op, version); - return true; + switch (op) + { + case Comparison.Eq: + case Comparison.Gte: + constraints.Add(new Constraint(Comparison.Gte, lo)); + constraints.Add(new Constraint(Comparison.Lt, hi)); + return true; + case Comparison.Gt: + constraints.Add(new Constraint(Comparison.Gt, lo)); + constraints.Add(new Constraint(Comparison.Lt, hi)); + return true; + case Comparison.Lte: + constraints.Add(new Constraint(Comparison.Lt, hi)); + return true; + case Comparison.Lt: + constraints.Add(new Constraint(Comparison.Lt, lo)); + return true; + case Comparison.Neq: + default: + return false; } } - private static bool TryParseComparatorPrefix(ReadOnlySpan s, out ComparatorOp op, out ReadOnlySpan remainder) + private static bool TryParseComparatorPrefix(ReadOnlySpan s, out Comparison op, out ReadOnlySpan remainder) { if (s.IsEmpty) { @@ -332,27 +346,27 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable', '=', ..]: - op = ComparatorOp.Gte; + op = Comparison.Gte; remainder = s[2..]; return true; case ['<', '=', ..]: - op = ComparatorOp.Lte; + op = Comparison.Lte; remainder = s[2..]; return true; case ['>', ..]: - op = ComparatorOp.Gt; + op = Comparison.Gt; remainder = s[1..]; return true; case ['<', ..]: - op = ComparatorOp.Lt; + op = Comparison.Lt; remainder = s[1..]; return true; case ['=', ..]: - op = ComparatorOp.Eq; + op = Comparison.Eq; remainder = s[1..]; return true; default: @@ -362,133 +376,86 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, List comparators) - { - s = s.Trim(); - if (s is ['*' or 'x' or 'X']) - { - // 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)) - { - return false; - } - - if (s is ['*' or 'x' or 'X']) - { - // bare wildcards only make sense when it is an exact version comparison. - return op == ComparatorOp.Eq; - } - - SemanticVersion hi; - - if (wildcard is 1) - { - hi = new SemanticVersion(lo.Major + 1, 0, 0); - } - else - { - 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.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; - } - private static bool TryParseMaven(ReadOnlySpan s, out SemanticVersionRange result) { result = default; - var groups = new List(); + var sets = new List(); - while (!s.IsEmpty) + foreach (var range in new MavenSetGrouping(s)) { - s = s.Trim(); - if (s.IsEmpty) - { - break; - } - - if (s[0] is not '[' and not '(') + if (!TryParseMavenSet(s[range].Trim(), out var set)) { return false; } - // Find matching close bracket - var close = FindMavenClose(s); - if (close < 0) - { - return false; - } - - var bracket = s[..(close + 1)]; - if (!TryParseMavenGroup(bracket, out var g)) - { - return false; - } - - groups.Add(g); - - s = s[(close + 1)..].Trim(); - if (!s.IsEmpty && s[0] == ',') - { - s = s[1..]; // OR separator between groups - } + sets.Add(set); } - if (groups.Count == 0) - { - return false; - } - - result = new SemanticVersionRange([.. groups]); + result = new SemanticVersionRange([.. sets]); return true; } - private static int FindMavenClose(ReadOnlySpan s) + private ref struct MavenSetGrouping { - for (var i = 1; i < s.Length; i++) + private readonly ReadOnlySpan _span; + private int _currentStart; + private int _currentEnd; + + public MavenSetGrouping(ReadOnlySpan readOnlySpan) { - if (s[i] is ']' or ')') - { - return i; - } + _span = readOnlySpan; + _currentStart = 0; + _currentEnd = -1; } - return -1; + public readonly Range Current => _currentStart.._currentEnd; + + public bool MoveNext() + { + _currentStart = _currentEnd is -1 ? 0 : _currentEnd + 1; + + if (_currentStart >= _span.Length) + { + return false; + } + + var depth = 0; + var i = _currentStart; + + while (i < _span.Length) + { + var ch = _span[i]; + + if (ch is '[' or '(') + { + depth++; + } + else if (ch is ']' or ')') + { + depth--; + } + else if (ch is ',' && depth is 0) + { + _currentEnd = i; + return true; + } + + i++; + } + + _currentEnd = _span.Length; + return true; + } + + public readonly MavenSetGrouping GetEnumerator() + { + return this; + } } - private static bool TryParseMavenGroup(ReadOnlySpan s, out ComparatorGroup group) + private static bool TryParseMavenSet(ReadOnlySpan s, out ConstraintSet set) { - group = default; + set = default; var loInclusive = s[0] is '['; var hiInclusive = s[^1] is ']'; @@ -507,11 +474,11 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable(); + var comps = new List(); if (s[..i].Trim() is { IsEmpty: false } loStr) { @@ -520,7 +487,7 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable @@ -24,70 +24,75 @@ public readonly partial record struct SemanticVersionRange /// true if the version is contained in the range, otherwise false. public bool Contains(SemanticVersion version) { - if (_groups is null || _groups.Length == 0) + if (_sets is null || _sets.Length == 0) { return false; } - return _groups.Any(group => group.Includes(version)); + return _sets.Any(set => set.Includes(version)); } } -internal enum ComparatorOp { Eq, Neq, Lt, Lte, Gt, Gte } +internal enum Comparison { Eq, Neq, Lt, Lte, Gt, Gte } -internal readonly struct Comparator +internal readonly struct Constraint { - public readonly ComparatorOp Op; + public readonly Comparison Operation; public readonly SemanticVersion Version; - public Comparator(ComparatorOp op, SemanticVersion version) + public Constraint(Comparison operation, SemanticVersion version) { - Op = op; + Operation = operation; Version = version; } public bool Includes(SemanticVersion v) { - return Op switch + return Operation switch { - ComparatorOp.Eq => v == Version, - ComparatorOp.Neq => v != Version, - ComparatorOp.Lt => v < Version, - ComparatorOp.Lte => v <= Version, - ComparatorOp.Gt => v > Version, - ComparatorOp.Gte => v >= Version, + Comparison.Eq => v == Version, + Comparison.Neq => v != Version, + Comparison.Lt => v < Version, + Comparison.Lte => v <= Version, + Comparison.Gt => v > Version, + Comparison.Gte => v >= Version, _ => false }; } public override string ToString() { - return Op switch + return Operation switch { - ComparatorOp.Neq => $"!={Version}", - ComparatorOp.Lt => $"<{Version}", - ComparatorOp.Lte => $"<={Version}", - ComparatorOp.Gt => $">{Version}", - ComparatorOp.Gte => $">={Version}", - _ => $"{Version}", + Comparison.Neq => $"!={Version}", + Comparison.Lt => $"<{Version}", + Comparison.Lte => $"<={Version}", + Comparison.Gt => $">{Version}", + Comparison.Gte => $">={Version}", + Comparison.Eq or _ => $"{Version}", }; } } // One AND-group of comparators (all must be satisfied). -internal readonly struct ComparatorGroup(Comparator[] comparators) +internal readonly struct ConstraintSet { - public readonly Comparator[] Comparators = comparators; + public readonly Constraint[] Constraints; - public Comparator? Upper => Comparators.Where(c => c.Op is ComparatorOp.Lt or ComparatorOp.Lte) - .Select(it => (Comparator?)it).FirstOrDefault(); + public ConstraintSet(Constraint[] constraints) + { + Constraints = constraints; + } - public Comparator? Lower => Comparators.Where(c => c.Op is ComparatorOp.Gt or ComparatorOp.Gte) - .Select(it => (Comparator?)it).FirstOrDefault(); + public Constraint? Upper => Constraints.Where(c => c.Operation is Comparison.Lt or Comparison.Lte) + .Select(it => (Constraint?)it).FirstOrDefault(); + + public Constraint? Lower => Constraints.Where(c => c.Operation is Comparison.Gt or Comparison.Gte) + .Select(it => (Constraint?)it).FirstOrDefault(); public bool Includes(SemanticVersion v) { - foreach (var c in Comparators) + foreach (var c in Constraints) { if (!c.Includes(v)) {