This commit is contained in:
Louis Seubert 2026-05-19 19:42:03 +02:00
commit 74035d5f13
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
4 changed files with 938 additions and 682 deletions

View file

@ -19,7 +19,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -31,7 +31,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -47,7 +47,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -64,7 +64,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -80,7 +80,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -94,7 +94,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -106,7 +106,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -125,7 +125,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -136,7 +136,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -148,7 +148,7 @@ internal sealed class SemanticVersionRangeTests
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
@ -165,7 +165,7 @@ internal sealed class SemanticVersionRangeTests
var json = "\"^1.2.3\"";
var r = System.Text.Json.JsonSerializer.Deserialize<SemanticVersionRange>(json);
await Assert.That(r.ToString()).IsEqualTo("^1.2.3");
await Assert.That(r.Satisfies(new SemanticVersion(1, 2, 4))).IsTrue();
await Assert.That(r.Contains(new SemanticVersion(1, 2, 4))).IsTrue();
}
[Test]
@ -200,4 +200,15 @@ internal sealed class SemanticVersionRangeTests
await Assert.That(success).IsTrue();
await Assert.That(new string(destination[..charsWritten])).IsEqualTo("^1.2.3");
}
[Test]
public async Task I_fail_formatting_when_the_tentative_short_form_overflows()
{
var value = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
var destination = new char[5];
var success = value.TryFormat(destination, out var charsWritten, "ns", null);
await Assert.That(success).IsFalse();
await Assert.That(charsWritten).IsEqualTo(0);
}
}

View file

@ -0,0 +1,75 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.CompilerServices;
namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersionRange : ISpanFormattable
{
#region IFormattable
/// <inheritdoc />
public string ToString(string? format, IFormatProvider? formatProvider)
{
if (format is not null and not "m" and not "n" and not "ns")
{
throw new FormatException($"The format string '{format}' is not supported.");
}
var handler = new DefaultInterpolatedStringHandler(0, 1, formatProvider);
handler.AppendFormatted(this, format);
return handler.ToStringAndClear();
}
#endregion
#region ISpanFormattable
/// <summary>
/// Tries to format the semantic version range into the specified span of characters.
/// </summary>
/// <see cref="TryFormat(Span{char},out int,ReadOnlySpan{char},IFormatProvider)">ISpanFormattable.TryFormat</see>
public bool TryFormat(Span<char> destination, out int charsWritten)
{
return TryFormat(destination, out charsWritten, default, null);
}
/// <inheritdoc />
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
if (format.IsEmpty)
{
format = "ns";
}
if (format is not "m" and not "n" and not "ns")
{
charsWritten = 0;
return false;
}
var builder = new SpanStringBuilder(destination, provider);
if (_sets is not { Length: > 0 } sets)
{
builder.AppendLiteral(format is "m" ? "(,)" : "*");
}
else
{
for (var i = 0; i < sets.Length; i++)
{
if (i > 0)
{
builder.AppendLiteral(format is "m" ? "," : " || ");
}
sets[i].AppendFormatted(ref builder, format);
}
}
return builder.TryComplete(out charsWritten);
}
#endregion
}

View file

@ -0,0 +1,462 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersionRange : ISpanParsable<SemanticVersionRange>
{
#region IParsable
/// <summary>
/// Parses a string into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="Parse(string, IFormatProvider)"/>
public static SemanticVersionRange Parse(string s)
{
return Parse(s.AsSpan(), null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(string s, IFormatProvider? provider)
{
return Parse(s.AsSpan(), provider);
}
/// <summary>
/// Tries to parse a string into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="TryParse(string, IFormatProvider, out SemanticVersionRange)"/>
public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s.AsSpan(), provider, out result);
}
#endregion
#region ISpanParsable
/// <summary>
/// Parses a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="Parse(ReadOnlySpan{char}, IFormatProvider)"/>
public static SemanticVersionRange Parse(ReadOnlySpan<char> s)
{
return Parse(s, null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
{
if (!TryParse(s, provider, out var result))
{
throw new FormatException($"The input string '{s}' was not in a correct format.");
}
return result;
}
/// <summary>
/// Tries to parse a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="TryParse(ReadOnlySpan{char}, IFormatProvider, out SemanticVersionRange)"/>
public static bool TryParse(ReadOnlySpan<char> s, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
result = default;
if (s.IsEmpty)
{
return false;
}
return s[0] is '[' or '(' ? TryParseJava(s, out result) : TryParseNode(s, out result);
}
#endregion
private static bool TryParseJava(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
var sets = new List<ConstraintSet>();
foreach (var range in new JavaSetGrouping(s))
{
if (s[range] is not { IsEmpty: false } part)
{
continue;
}
if (!TryParseJavaSet(part, out var set))
{
result = default;
return false;
}
sets.Add(set);
}
result = new SemanticVersionRange([.. sets]);
return true;
}
private static bool TryParseJavaSet(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
if (s.IsEmpty)
{
return false;
}
if (s is not ['[' or '(', .., ']' or ')'])
{
if (!SemanticVersion.TryParsePartially(s, out var version, out _))
{
return false;
}
set = new ConstraintSet([new Constraint(lower: (version, true), upper: null)]);
return true;
}
var inclusiveStart = s[0] is '[';
var inclusiveEnd = s[^1] is ']';
var text = s[1..^1];
var comma = text.IndexOf(',');
if (comma < 0)
{
if (!inclusiveStart || !inclusiveEnd || !SemanticVersion.TryParsePartially(text.Trim(), out var version, out _))
{
return false;
}
set = new ConstraintSet([new Constraint(Comparator.Equal, version)]);
return true;
}
if (!TryCreateBound(text[..comma].Trim(), inclusiveStart, out var lower) ||
!TryCreateBound(text[(comma + 1)..].Trim(), inclusiveEnd, out var upper))
{
return false;
}
set = new ConstraintSet(lower is null && upper is null ? [] : [new Constraint(lower: lower, upper: upper)]);
return true;
}
private static bool TryCreateBound(ReadOnlySpan<char> s, bool inclusive, out (SemanticVersion Version, bool Inclusive)? bound)
{
if (s.IsEmpty)
{
bound = null;
return true;
}
if (!SemanticVersion.TryParsePartially(s, out var version, out _))
{
bound = null;
return false;
}
bound = (version, inclusive);
return true;
}
private ref struct JavaSetGrouping
{
private readonly ReadOnlySpan<char> _span;
private int _currentStart;
private int _currentEnd;
public JavaSetGrouping(ReadOnlySpan<char> readOnlySpan)
{
_span = readOnlySpan;
_currentStart = 0;
_currentEnd = -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 JavaSetGrouping GetEnumerator()
{
return this;
}
}
private static bool TryParseNode(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
var sets = new List<ConstraintSet>();
foreach (var range in new NodeSetGrouping(s))
{
if (s[range] is not { IsEmpty: false } part)
{
continue;
}
if (!TryParseNodeSet(part, out var set))
{
result = default;
return false;
}
sets.Add(set);
}
result = new SemanticVersionRange([.. sets]);
return true;
}
private static bool TryParseNodeSet(ReadOnlySpan<char> s, out ConstraintSet set)
{
if (s.IndexOf(" - ") is not -1 and var hyphen)
{
if (!SemanticVersion.TryParse(s[..hyphen].Trim(), out var lower))
{
set = default;
return false;
}
if (!SemanticVersion.TryParse(s[(hyphen + 3)..].Trim(), out var upper))
{
set = default;
return false;
}
set = new ConstraintSet([new Constraint(lower: (lower, true), upper: (upper, true))]);
return true;
}
var list = new List<Constraint>(2);
var current = s;
while (TryReadToken(ref current, out var token))
{
scoped ReadOnlySpan<char> instruction = [];
if (token is ">=" or "<=" or ">" or "<" or "~" or "^" or "=" or "!=")
{
instruction = token;
if (!TryReadToken(ref current, out token))
{
set = default;
return false;
}
}
else
{
if (token.StartsWith(">="))
{
instruction = ">=";
token = token[2..];
}
else if (token.StartsWith("<="))
{
instruction = "<=";
token = token[2..];
}
else if (token.StartsWith("!="))
{
instruction = "!=";
token = token[2..];
}
else if (token.StartsWith(">"))
{
instruction = ">";
token = token[1..];
}
else if (token.StartsWith("<"))
{
instruction = "<";
token = token[1..];
}
else if (token.StartsWith("^"))
{
instruction = "^";
token = token[1..];
}
else if (token.StartsWith("~"))
{
instruction = "~";
token = token[1..];
}
else if (token.StartsWith("="))
{
instruction = "=";
token = token[1..];
}
}
if (token is "*" or "x" or "X")
{
set = new ConstraintSet([default]);
return true;
}
if (!TryParseNpmConstraint(instruction, token.Trim(), list))
{
set = default;
return false;
}
}
set = new ConstraintSet([.. list]);
return true;
}
private static bool TryReadToken(ref ReadOnlySpan<char> s, out ReadOnlySpan<char> token)
{
s = s.TrimStart();
if (s.IsEmpty)
{
token = default;
return false;
}
var separator = s.IndexOf(' ');
if (separator < 0)
{
token = s;
s = default;
return true;
}
token = s[..separator];
s = s[separator..];
return true;
}
private static bool TryParseNpmConstraint(ReadOnlySpan<char> op, ReadOnlySpan<char> s, ICollection<Constraint> constraints)
{
if (!SemanticVersion.TryParsePartially(s.Trim(), out var version, out var components))
{
return false;
}
switch (op)
{
case "^":
constraints.Add(new Constraint(lower: (version, true), upper: (GetCaretUpperBound(version, components), false)));
return true;
case "~":
constraints.Add(new Constraint(lower: (version, true), upper: (GetTildeUpperBound(version, components), false)));
return true;
case ">":
constraints.Add(new Constraint(Comparator.Greater, version));
return true;
case ">=":
constraints.Add(new Constraint(Comparator.GreaterOrEqual, version));
return true;
case "<":
constraints.Add(new Constraint(Comparator.Less, version));
return true;
case "<=":
constraints.Add(new Constraint(Comparator.LessOrEqual, version));
return true;
case "!=":
constraints.Add(new Constraint(Comparator.NotEqual, version));
return true;
default:
switch (components)
{
case 3:
constraints.Add(new Constraint(Comparator.Equal, version));
return true;
case 2:
constraints.Add(new Constraint(lower: (version, true), upper: (new SemanticVersion(version.Major, version.Minor + 1, 0), false)));
return true;
case 1:
constraints.Add(new Constraint(lower: (version, true), upper: (new SemanticVersion(version.Major + 1, 0, 0), false)));
return true;
default:
return false;
}
}
}
private ref struct NodeSetGrouping
{
private readonly ReadOnlySpan<char> _span;
private int _currentStart;
private int _currentEnd;
public NodeSetGrouping(ReadOnlySpan<char> span)
{
_span = span;
_currentStart = 0;
_currentEnd = -1;
}
public readonly Range Current => _currentStart.._currentEnd;
public bool MoveNext()
{
_currentStart = _currentEnd is -1 ? 0 : _currentEnd + 2;
if (_currentStart >= _span.Length)
{
return false;
}
var index = _span[_currentStart..].IndexOf("||");
_currentEnd = index >= 0 ? _currentStart + index : _span.Length;
return true;
}
public readonly NodeSetGrouping GetEnumerator()
{
return this;
}
}
}

File diff suppressed because it is too large Load diff