From 797a1d37214e8b0593237522365ea8273065690d Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 16 May 2026 23:04:09 +0200 Subject: [PATCH] wip --- .forgejo/workflows/default.yml | 4 + .forgejo/workflows/release.yml | 4 + src/semver.tests/SemanticVersionRangeTests.cs | 37 +- src/semver.tests/SemanticVersionTests.cs | 28 +- src/semver/Geekeey.SemVer.csproj | 2 +- src/semver/SemanticVersion.Comparison.cs | 48 ++ src/semver/SemanticVersion.Formatting.cs | 114 ++++ src/semver/SemanticVersion.Parsing.cs | 132 ++++ src/semver/SemanticVersion.cs | 396 ----------- src/semver/SemanticVersionJsonConverter.cs | 8 +- src/semver/SemanticVersionRange.Formatting.cs | 465 +++++++++++++ src/semver/SemanticVersionRange.Parsing.cs | 502 ++++++++++++++ src/semver/SemanticVersionRange.cs | 614 ++---------------- .../SemanticVersionRangeJsonConverter.cs | 30 +- 14 files changed, 1387 insertions(+), 997 deletions(-) create mode 100644 src/semver/SemanticVersion.Comparison.cs create mode 100644 src/semver/SemanticVersion.Formatting.cs create mode 100644 src/semver/SemanticVersion.Parsing.cs create mode 100644 src/semver/SemanticVersionRange.Formatting.cs create mode 100644 src/semver/SemanticVersionRange.Parsing.cs diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml index a007574..4d33189 100644 --- a/.forgejo/workflows/default.yml +++ b/.forgejo/workflows/default.yml @@ -34,6 +34,10 @@ jobs: run: | dotnet pack -p:ContinuousIntegrationBuild=true + - name: dotnet format --verify-no-changes + run: | + dotnet format --no-restore --verify-no-changes --verbosity normal + - name: dotnet test run: | dotnet test -p:ContinuousIntegrationBuild=true \ No newline at end of file diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 603a9d6..0c7b1dc 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -25,6 +25,10 @@ jobs: run: | dotnet pack -p:ContinuousIntegrationBuild=true + - name: dotnet format --verify-no-changes + run: | + dotnet format --no-restore --verify-no-changes --verbosity normal + - name: dotnet test run: | dotnet test -p:ContinuousIntegrationBuild=true diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index 8f8de02..f87225a 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -1,6 +1,8 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Text; + namespace Geekeey.SemVer.Tests; internal sealed class SemanticVersionRangeTests @@ -152,7 +154,7 @@ internal sealed class SemanticVersionRangeTests [Test] public async Task I_can_serialize_to_json() { - var r = SemanticVersionRange.Parse("^1.2.3"); + var r = SemanticVersionRange.Parse("[1.2.3,2.0.0)"); var json = System.Text.Json.JsonSerializer.Serialize(r); await Assert.That(json).IsEqualTo("\"^1.2.3\""); } @@ -165,4 +167,37 @@ internal sealed class SemanticVersionRangeTests await Assert.That(r.ToString()).IsEqualTo("^1.2.3"); await Assert.That(r.Satisfies(new SemanticVersion(1, 2, 4))).IsTrue(); } + + [Test] + public async Task I_use_npm_short_format_by_default() + { + var value = SemanticVersionRange.Parse("[1.2.3,2.0.0)"); + await Assert.That(value.ToString()).IsEqualTo("^1.2.3"); + } + + [Test] + [Arguments("^1.2.3", "m", "[1.2.3,2.0.0)")] + [Arguments("~1.2.3", "m", "[1.2.3,1.3.0)")] + [Arguments("1.2.3", "m", "[1.2.3]")] + [Arguments("[1.2.3,2.0.0)", "n", ">=1.2.3 <2.0.0")] + [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("*", "m", "(,)")] + public async Task I_can_convert_range_formats(string range, string format, string expected) + { + var value = SemanticVersionRange.Parse(range); + await Assert.That(value.ToString(format, null)).IsEqualTo(expected); + } + + [Test] + public async Task I_can_format_ranges_to_chars() + { + var value = SemanticVersionRange.Parse("[1.2.3,2.0.0)"); + var destination = new char[32]; + var success = value.TryFormat(destination, out var charsWritten, "ns", null); + + await Assert.That(success).IsTrue(); + await Assert.That(new string(destination[..charsWritten])).IsEqualTo("^1.2.3"); + } } \ No newline at end of file diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index c497cc6..3494fd0 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -13,6 +13,7 @@ internal sealed class SemanticVersionTests { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + [Test] [Arguments(0, 0, 0, 0)] [Arguments(1, 1, 1, 0)] @@ -137,17 +138,6 @@ internal sealed class SemanticVersionTests await Assert.That(result.Metadata).IsEqualTo("build.1"); } - [Test] - public async Task I_can_parse_from_utf8() - { - var utf8 = "2.0.0-rc.1"u8; - var result = SemanticVersion.Parse(utf8); - await Assert.That(result.Major).IsEqualTo(2UL); - await Assert.That(result.Minor).IsEqualTo(0UL); - await Assert.That(result.Patch).IsEqualTo(0UL); - await Assert.That(result.Prerelease).IsEqualTo("rc.1"); - } - [Test] [Arguments(1, 2, 3, null, null, "1.2.3")] [Arguments(1, 2, 3, "alpha", null, "1.2.3-alpha")] @@ -172,17 +162,6 @@ internal sealed class SemanticVersionTests await Assert.That(new string(dest[..charsWritten])).IsEqualTo("1.2.3-beta+456"); } - [Test] - public async Task I_can_format_to_utf8() - { - var v = new SemanticVersion(1, 2, 3, "beta", "456"); - var dest = new byte[32]; - var success = v.TryFormat(dest, out var bytesWritten, "f", null); - - await Assert.That(success).IsTrue(); - await Assert.That(Encoding.UTF8.GetString(dest[..bytesWritten])).IsEqualTo("1.2.3-beta+456"); - } - [Test] [Arguments(1, 2, 3, null, null, "\"1.2.3\"")] [Arguments(1, 2, 3, "alpha", null, "\"1.2.3-alpha\"")] @@ -221,10 +200,7 @@ internal sealed class SemanticVersionTests [Test] public async Task I_can_serialize_as_part_of_object() { - var obj = new - { - Version = new SemanticVersion(1, 0, 0, "rc.1", "metadata") - }; + 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\"}"); diff --git a/src/semver/Geekeey.SemVer.csproj b/src/semver/Geekeey.SemVer.csproj index 2ad4851..3851488 100644 --- a/src/semver/Geekeey.SemVer.csproj +++ b/src/semver/Geekeey.SemVer.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/semver/SemanticVersion.Comparison.cs b/src/semver/SemanticVersion.Comparison.cs new file mode 100644 index 0000000..b6e618f --- /dev/null +++ b/src/semver/SemanticVersion.Comparison.cs @@ -0,0 +1,48 @@ +namespace Geekeey.SemVer; + +public readonly partial record struct SemanticVersion : IComparable, IComparable +{ + /// + public int CompareTo(object? obj) + { + return obj is not SemanticVersion other ? 1 : CompareTo(other); + } + + /// + public int CompareTo(SemanticVersion other) + { + return SemanticVersionComparer.Priority.Compare(this, other); + } + + /// + /// Determines whether one version is less than another version. + /// + public static bool operator <(SemanticVersion left, SemanticVersion right) + { + return left.CompareTo(right) < 0; + } + + /// + /// Determines whether one version is less than or equal to another version. + /// + public static bool operator <=(SemanticVersion left, SemanticVersion right) + { + return left.CompareTo(right) <= 0; + } + + /// + /// Determines whether one version is greater than another version. + /// + public static bool operator >(SemanticVersion left, SemanticVersion right) + { + return left.CompareTo(right) > 0; + } + + /// + /// Determines whether one version is greater than or equal to another version. + /// + public static bool operator >=(SemanticVersion left, SemanticVersion right) + { + return left.CompareTo(right) >= 0; + } +} diff --git a/src/semver/SemanticVersion.Formatting.cs b/src/semver/SemanticVersion.Formatting.cs new file mode 100644 index 0000000..c6a0992 --- /dev/null +++ b/src/semver/SemanticVersion.Formatting.cs @@ -0,0 +1,114 @@ +using System.Buffers; +using System.Globalization; + +namespace Geekeey.SemVer; + +public readonly partial record struct SemanticVersion : ISpanFormattable +{ + internal int RequiredBufferSize + { + get + { + var length = 0; + length += ulong.CountDigits(Major) + 1; + length += ulong.CountDigits(Minor) + 1; + length += ulong.CountDigits(Patch); + length += Prerelease is { Length: > 0 } ? 1 + Prerelease.Length : 0; + length += Metadata is { Length: > 0 } ? 1 + Metadata.Length : 0; + return length; + } + } + + #region IFormattable + + /// + /// + /// s - Default SemVer - [1.2.3-beta.4] + /// f - Full SemVer - [1.2.3-beta.4+5] + /// r - Just the SemVer part relevant for compatibility comparison - [1.2.3] + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + var capacity = RequiredBufferSize; + var shared = capacity > 256 ? ArrayPool.Shared.Rent(capacity) : null; + try + { + scoped var buffer = shared.AsSpan(); + + if (shared is null) + { + buffer = stackalloc char[capacity]; + } + + _ = TryFormat(buffer, out var bytesWritten, format, formatProvider); + + return new string(buffer[..bytesWritten]); + } + finally + { + if (shared is not null) + { + ArrayPool.Shared.Return(shared); + } + } + } + + #endregion + + #region ISpanFormattable + + /// + /// Tries to format the semantic version into the specified span of characters. + /// + /// ISpanFormattable.TryFormat + public bool TryFormat(Span destination, out int charsWritten) + { + return TryFormat(destination, out charsWritten, default, null); + } + + /// + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (!destination.TryWrite(NumberFormatInfo.InvariantInfo, $"{Major}.{Minor}.{Patch}", out charsWritten)) + { + charsWritten = 0; + return false; + } + + destination = destination[charsWritten..]; + + if (Prerelease is { Length: > 0 } && format is "s" or "f") + { + destination[0] = '-'; + + if (!Prerelease.AsSpan().TryCopyTo(destination[1..])) + { + charsWritten = 0; + return false; + } + + destination = destination[(Prerelease.Length + 1)..]; + charsWritten += Prerelease.Length + 1; + } + + if (Metadata is { Length: > 0 } && format is "f") + { + destination[0] = '+'; + + if (!Metadata.AsSpan().TryCopyTo(destination[1..])) + { + charsWritten = 0; + return false; + } + + destination = destination[(Metadata.Length + 1)..]; + charsWritten += Metadata.Length + 1; + } + + _ = destination; + + return true; + } + + #endregion +} \ No newline at end of file diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs new file mode 100644 index 0000000..fc3a0cf --- /dev/null +++ b/src/semver/SemanticVersion.Parsing.cs @@ -0,0 +1,132 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Geekeey.SemVer; + +public readonly partial record struct SemanticVersion : ISpanParsable +{ + #region IParsable + + /// + /// Parses a string into a . + /// + /// + public static SemanticVersion Parse(string s) + { + return Parse(s.AsSpan(), null); + } + + /// + public static SemanticVersion Parse(string s, IFormatProvider? provider) + { + return Parse(s.AsSpan(), provider); + } + + /// + /// Tries to parse a string into a . + /// + /// + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersion result) + { + return TryParse(s, null, out result); + } + + /// + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result) + { + return TryParse(s.AsSpan(), provider, out result); + } + + #endregion + + #region ISpanParsable + + /// + /// Parses a span of characters into a . + /// + /// + public static SemanticVersion Parse(ReadOnlySpan s) + { + return Parse(s, null); + } + + /// + public static SemanticVersion Parse(ReadOnlySpan s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var version)) + { + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + return version; + } + + /// + /// Tries to parse a span of characters into a . + /// + /// + public static bool TryParse(ReadOnlySpan s, [MaybeNullWhen(false)] out SemanticVersion result) + { + return TryParse(s, null, out result); + } + + /// + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result) + { + result = default; + if (s.IsEmpty) + { + return false; + } + + var metadata = default(string); + var plusIndex = s.IndexOf('+'); + if (plusIndex >= 0) + { + metadata = s[(plusIndex + 1)..].ToString(); + s = s[..plusIndex]; + } + + var prerelease = default(string); + var dashIndex = s.IndexOf('-'); + if (dashIndex >= 0) + { + prerelease = s[(dashIndex + 1)..].ToString(); + s = s[..dashIndex]; + } + + var firstDot = s.IndexOf('.'); + if (firstDot < 0) + { + return false; + } + + if (!ulong.TryParse(s[..firstDot], NumberStyles.None, CultureInfo.InvariantCulture, out var major)) + { + return false; + } + + s = s[(firstDot + 1)..]; + var secondDot = s.IndexOf('.'); + if (secondDot < 0) + { + return false; + } + + if (!ulong.TryParse(s[..secondDot], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)) + { + return false; + } + + s = s[(secondDot + 1)..]; + if (!ulong.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var patch)) + { + return false; + } + + result = new SemanticVersion(major, minor, patch, prerelease, metadata); + return true; + } + + #endregion +} \ No newline at end of file diff --git a/src/semver/SemanticVersion.cs b/src/semver/SemanticVersion.cs index c156d88..a2a9de5 100644 --- a/src/semver/SemanticVersion.cs +++ b/src/semver/SemanticVersion.cs @@ -1,9 +1,4 @@ -using System.Buffers; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Text; using System.Text.Json.Serialization; -using System.Text.Unicode; namespace Geekeey.SemVer; @@ -117,394 +112,3 @@ public readonly partial record struct SemanticVersion return ToString(null, null); } } - -public readonly partial record struct SemanticVersion : IComparable, IComparable -{ - /// - public int CompareTo(object? obj) - { - return obj is not SemanticVersion other ? 1 : CompareTo(other); - } - - /// - public int CompareTo(SemanticVersion other) - { - return SemanticVersionComparer.Priority.Compare(this, other); - } - - /// - /// Determines whether one version is less than another version. - /// - public static bool operator <(SemanticVersion left, SemanticVersion right) - { - return left.CompareTo(right) < 0; - } - - /// - /// Determines whether one version is less than or equal to another version. - /// - public static bool operator <=(SemanticVersion left, SemanticVersion right) - { - return left.CompareTo(right) <= 0; - } - - /// - /// Determines whether one version is greater than another version. - /// - public static bool operator >(SemanticVersion left, SemanticVersion right) - { - return left.CompareTo(right) > 0; - } - - /// - /// Determines whether one version is greater than or equal to another version. - /// - public static bool operator >=(SemanticVersion left, SemanticVersion right) - { - return left.CompareTo(right) >= 0; - } -} - -public readonly partial record struct SemanticVersion : ISpanParsable, IUtf8SpanParsable -{ - #region IParsable - - /// - /// Parses a string into a . - /// - /// - public static SemanticVersion Parse(string s) - { - return Parse(s.AsSpan(), null); - } - - /// - public static SemanticVersion Parse(string s, IFormatProvider? provider) - { - return Parse(s.AsSpan(), provider); - } - - /// - /// Tries to parse a string into a . - /// - /// - public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersion result) - { - return TryParse(s, null, out result); - } - - /// - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result) - { - return TryParse(s.AsSpan(), provider, out result); - } - - #endregion - - #region ISpanParsable - - /// - /// Parses a span of characters into a . - /// - /// - public static SemanticVersion Parse(ReadOnlySpan s) - { - return Parse(s, null); - } - - /// - public static SemanticVersion Parse(ReadOnlySpan s, IFormatProvider? provider) - { - if (!TryParse(s, provider, out var version)) - { - throw new FormatException($"The input string '{s}' was not in a correct format."); - } - - return version; - } - - /// - /// Tries to parse a span of characters into a . - /// - /// - public static bool TryParse(ReadOnlySpan s, [MaybeNullWhen(false)] out SemanticVersion result) - { - return TryParse(s, null, out result); - } - - /// - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result) - { - result = default; - if (s.IsEmpty) - { - return false; - } - - var metadata = default(string); - var plusIndex = s.IndexOf('+'); - if (plusIndex >= 0) - { - metadata = s[(plusIndex + 1)..].ToString(); - s = s[..plusIndex]; - } - - var prerelease = default(string); - var dashIndex = s.IndexOf('-'); - if (dashIndex >= 0) - { - prerelease = s[(dashIndex + 1)..].ToString(); - s = s[..dashIndex]; - } - - var firstDot = s.IndexOf('.'); - if (firstDot < 0) - { - return false; - } - - if (!ulong.TryParse(s[..firstDot], NumberStyles.None, CultureInfo.InvariantCulture, out var major)) - { - return false; - } - - s = s[(firstDot + 1)..]; - var secondDot = s.IndexOf('.'); - if (secondDot < 0) - { - return false; - } - - if (!ulong.TryParse(s[..secondDot], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)) - { - return false; - } - - s = s[(secondDot + 1)..]; - if (!ulong.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var patch)) - { - return false; - } - - result = new SemanticVersion(major, minor, patch, prerelease, metadata); - return true; - } - - #endregion - - #region IUtf8SpanParsable - - /// - /// Parses a span of UTF-8 bytes into a . - /// - /// - public static SemanticVersion Parse(ReadOnlySpan utf8Text) - { - return Parse(utf8Text, null); - } - - /// - public static SemanticVersion Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) - { - if (!TryParse(utf8Text, provider, out var version)) - { - throw new FormatException($"The input string '{Encoding.UTF8.GetString(utf8Text)}' was not in a correct format."); - } - - return version; - } - - /// - /// Tries to parse a span of UTF-8 bytes into a . - /// - /// - public static bool TryParse(ReadOnlySpan utf8Text, [MaybeNullWhen(false)] out SemanticVersion result) - { - return TryParse(utf8Text, null, out result); - } - - /// - public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result) - { - if (utf8Text.IsEmpty) - { - result = default; - return false; - } - - var charCount = Encoding.UTF8.GetCharCount(utf8Text); - var chars = charCount <= 256 ? stackalloc char[charCount] : new char[charCount]; - Encoding.UTF8.GetChars(utf8Text, chars); - - return TryParse(chars, provider, out result); - } - - #endregion -} - -public readonly partial record struct SemanticVersion : ISpanFormattable, IUtf8SpanFormattable -{ - internal int RequiredBufferSize - { - get - { - var length = 0; - length += ulong.CountDigits(Major) + 1; - length += ulong.CountDigits(Minor) + 1; - length += ulong.CountDigits(Patch); - length += Prerelease is { Length: > 0 } ? 1 + Prerelease.Length : 0; - length += Metadata is { Length: > 0 } ? 1 + Metadata.Length : 0; - return length; - } - } - - #region IFormattable - - /// - /// - /// s - Default SemVer - [1.2.3-beta.4] - /// f - Full SemVer - [1.2.3-beta.4+5] - /// r - Just the SemVer part relevant for compatibility comparison - [1.2.3] - /// - public string ToString(string? format, IFormatProvider? formatProvider) - { - var capacity = RequiredBufferSize; - var shared = capacity > 256 ? ArrayPool.Shared.Rent(capacity) : null; - try - { - scoped var buffer = shared.AsSpan(); - - if (shared is null) - { - buffer = stackalloc char[capacity]; - } - - _ = TryFormat(buffer, out var bytesWritten, format, formatProvider); - - return new string(buffer[..bytesWritten]); - } - finally - { - if (shared is not null) - { - ArrayPool.Shared.Return(shared); - } - } - } - - #endregion - - #region ISpanFormattable - - /// - /// Tries to format the semantic version into the specified span of characters. - /// - /// ISpanFormattable.TryFormat - public bool TryFormat(Span destination, out int charsWritten) - { - return TryFormat(destination, out charsWritten, default, null); - } - - /// - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) - { - if (!destination.TryWrite(NumberFormatInfo.InvariantInfo, $"{Major}.{Minor}.{Patch}", out charsWritten)) - { - charsWritten = 0; - return false; - } - - destination = destination[charsWritten..]; - - if (Prerelease is { Length: > 0 } && format is "s" or "f") - { - destination[0] = '-'; - - if (!Prerelease.AsSpan().TryCopyTo(destination[1..])) - { - charsWritten = 0; - return false; - } - - destination = destination[(Prerelease.Length + 1)..]; - charsWritten += Prerelease.Length + 1; - } - - if (Metadata is { Length: > 0 } && format is "f") - { - destination[0] = '+'; - - if (!Metadata.AsSpan().TryCopyTo(destination[1..])) - { - charsWritten = 0; - return false; - } - - destination = destination[(Metadata.Length + 1)..]; - charsWritten += Metadata.Length + 1; - } - - _ = destination; - - return true; - } - - #endregion - - #region IUtf8SpanFormattable - - /// - /// Tries to format the semantic version into the specified span of UTF-8 bytes. - /// - /// IUtf8SpanFormattable.TryFormat - public bool TryFormat(Span utf8Destination, out int bytesWritten) - { - return TryFormat(utf8Destination, out bytesWritten, default, null); - } - - /// - public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) - { - if (!Utf8.TryWrite(utf8Destination, NumberFormatInfo.InvariantInfo, $"{Major}.{Minor}.{Patch}", out bytesWritten)) - { - bytesWritten = 0; - return false; - } - - utf8Destination = utf8Destination[bytesWritten..]; - - if (Prerelease is { Length: > 0 } && format is "s" or "f") - { - utf8Destination[0] = (byte)'-'; - - if (Utf8.FromUtf16(Prerelease, utf8Destination[1..], out _, out var b) is not OperationStatus.Done) - { - bytesWritten = 0; - return false; - } - - utf8Destination = utf8Destination[(b + 1)..]; - bytesWritten += b + 1; - } - - if (Metadata is { Length: > 0 } && format is "f") - { - utf8Destination[0] = (byte)'+'; - - if (Utf8.FromUtf16(Metadata, utf8Destination[1..], out _, out var b) is not OperationStatus.Done) - { - bytesWritten = 0; - return false; - } - - utf8Destination = utf8Destination[(b + 1)..]; - bytesWritten += b + 1; - } - - _ = utf8Destination; - - return true; - } - - #endregion -} \ No newline at end of file diff --git a/src/semver/SemanticVersionJsonConverter.cs b/src/semver/SemanticVersionJsonConverter.cs index 3f2ea50..4921d00 100644 --- a/src/semver/SemanticVersionJsonConverter.cs +++ b/src/semver/SemanticVersionJsonConverter.cs @@ -25,7 +25,7 @@ internal sealed class SemanticVersionJsonConverter : JsonConverter StackBufferThreshold ? ArrayPool.Shared.Rent(capacity) : null; + var shared = capacity > StackBufferThreshold ? ArrayPool.Shared.Rent(capacity) : null; try { scoped var buffer = shared.AsSpan(); if (shared is null) { - buffer = stackalloc byte[capacity]; + buffer = stackalloc char[capacity]; } _ = value.TryFormat(buffer, out var bytesWritten, "f", null); @@ -54,7 +54,7 @@ internal sealed class SemanticVersionJsonConverter : JsonConverter.Shared.Return(shared); + ArrayPool.Shared.Return(shared); } } } diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs new file mode 100644 index 0000000..f41b546 --- /dev/null +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -0,0 +1,465 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Buffers; +using System.Text.Unicode; + +namespace Geekeey.SemVer; + +public readonly partial record struct SemanticVersionRange : ISpanFormattable +{ + private const int StackBufferThreshold = 256; + + #region IFormattable + + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (!TryGetFormat(format, out var rangeFormat)) + { + throw new FormatException($"The format string '{format}' was not recognized."); + } + + var capacity = GetRequiredBufferSize(rangeFormat); + var shared = capacity > StackBufferThreshold ? ArrayPool.Shared.Rent(capacity) : null; + try + { + scoped var buffer = shared.AsSpan(); + + if (shared is null) + { + buffer = stackalloc char[capacity]; + } + + _ = TryFormatCore(buffer, out var charsWritten, rangeFormat); + return new string(buffer[..charsWritten]); + } + finally + { + if (shared is not null) + { + ArrayPool.Shared.Return(shared); + } + } + } + + #endregion + + #region ISpanFormattable + + /// + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (!TryGetFormat(format, out var rangeFormat)) + { + charsWritten = 0; + return false; + } + + return TryFormatCore(destination, out charsWritten, rangeFormat); + } + + #endregion + + private bool TryFormatCore(Span destination, out int charsWritten, RangeStringFormat format) + { + charsWritten = 0; + + if (_sets is not { Length: > 0 } sets) + { + return TryWriteLiteral(ref destination, ref charsWritten, format is RangeStringFormat.Maven ? "(,)" : "*"); + } + + for (var i = 0; i < sets.Length; i++) + { + if (i > 0 && !TryWriteLiteral(ref destination, ref charsWritten, format is RangeStringFormat.Maven ? "," : " || ")) + { + charsWritten = 0; + return false; + } + + if (!TryWriteSet(ref destination, ref charsWritten, sets[i], format)) + { + charsWritten = 0; + return false; + } + } + + return true; + } + + private int GetRequiredBufferSize(RangeStringFormat format) + { + if (_sets is not { Length: > 0 } sets) + { + return format is RangeStringFormat.Maven ? 3 : 1; + } + + var length = 0; + for (var i = 0; i < sets.Length; i++) + { + if (i > 0) + { + length += format is RangeStringFormat.Maven ? 1 : 4; + } + + length += GetSetRequiredBufferSize(sets[i], format); + } + + return length; + } + + private static int GetSetRequiredBufferSize(VersionConstraint[] set, RangeStringFormat format) + { + AnalyzeSet(set, out var effectiveCount, out var hasExact, out var exact, out var hasLower, out var lower, out var hasUpper, out var upper, out var hasUnsupported); + + if (hasUnsupported) + { + return GetConstraintsRequiredBufferSize(set, false); + } + + if (effectiveCount is 0) + { + return format is RangeStringFormat.Maven ? 3 : 1; + } + + if (hasExact && effectiveCount is 1) + { + return exact.Version.RequiredBufferSize + (format is RangeStringFormat.Maven ? 2 : 0); + } + + if (format is RangeStringFormat.NpmShort && hasLower && hasUpper && effectiveCount is 2 && TryGetShortNpmOperator(lower, upper, out _)) + { + return lower.Version.RequiredBufferSize + 1; + } + + if (format is RangeStringFormat.Maven) + { + return (hasLower ? lower.Version.RequiredBufferSize : 0) + + (hasUpper ? upper.Version.RequiredBufferSize : 0) + + 3; + } + + return GetConstraintsRequiredBufferSize(set, true); + } + + private static int GetConstraintsRequiredBufferSize(VersionConstraint[] set, bool bareEquals) + { + var length = 0; + var written = 0; + foreach (var constraint in set) + { + if (constraint.Comparator is Comparator.Any) + { + continue; + } + + if (written > 0) + { + length++; + } + + length += GetConstraintRequiredBufferSize(constraint, bareEquals); + written++; + } + + return written is 0 ? 1 : length; + } + + private static int GetConstraintRequiredBufferSize(VersionConstraint constraint, bool bareEquals) + { + return constraint.Comparator switch + { + Comparator.Equal when bareEquals => constraint.Version.RequiredBufferSize, + Comparator.Equal => constraint.Version.RequiredBufferSize + 1, + Comparator.GreaterThan => constraint.Version.RequiredBufferSize + 1, + Comparator.GreaterThanOrEqual => constraint.Version.RequiredBufferSize + 2, + Comparator.LessThan => constraint.Version.RequiredBufferSize + 1, + Comparator.LessThanOrEqual => constraint.Version.RequiredBufferSize + 2, + Comparator.NotEqual => constraint.Version.RequiredBufferSize + 2, + _ => 0 + }; + } + + private static bool TryWriteSet(ref Span destination, ref int charsWritten, VersionConstraint[] set, RangeStringFormat format) + { + AnalyzeSet(set, out var effectiveCount, out var hasExact, out var exact, out var hasLower, out var lower, out var hasUpper, out var upper, out var hasUnsupported); + + if (hasUnsupported) + { + return TryWriteConstraints(ref destination, ref charsWritten, set, false); + } + + if (effectiveCount is 0) + { + return TryWriteLiteral(ref destination, ref charsWritten, format is RangeStringFormat.Maven ? "(,)" : "*"); + } + + if (hasExact && effectiveCount is 1) + { + if (format is RangeStringFormat.Maven && !TryWriteLiteral(ref destination, ref charsWritten, "[")) + { + return false; + } + + if (!TryWriteVersion(ref destination, ref charsWritten, exact.Version)) + { + return false; + } + + return format is not RangeStringFormat.Maven || TryWriteLiteral(ref destination, ref charsWritten, "]"); + } + + if (format is RangeStringFormat.NpmShort && hasLower && hasUpper && effectiveCount is 2 && TryGetShortNpmOperator(lower, upper, out var shortOperator)) + { + return TryWriteChar(ref destination, ref charsWritten, shortOperator) && + TryWriteVersion(ref destination, ref charsWritten, lower.Version); + } + + if (format is RangeStringFormat.Maven) + { + return TryWriteMavenSet(ref destination, ref charsWritten, hasLower, lower, hasUpper, upper); + } + + return TryWriteConstraints(ref destination, ref charsWritten, set, true); + } + + private static bool TryWriteMavenSet(ref Span destination, ref int charsWritten, bool hasLower, VersionConstraint lower, bool hasUpper, VersionConstraint upper) + { + if (!TryWriteChar(ref destination, ref charsWritten, hasLower && lower.Comparator is Comparator.GreaterThanOrEqual ? '[' : '(')) + { + return false; + } + + if (hasLower && !TryWriteVersion(ref destination, ref charsWritten, lower.Version)) + { + return false; + } + + if (!TryWriteChar(ref destination, ref charsWritten, ',')) + { + return false; + } + + if (hasUpper && !TryWriteVersion(ref destination, ref charsWritten, upper.Version)) + { + return false; + } + + return TryWriteChar(ref destination, ref charsWritten, hasUpper && upper.Comparator is Comparator.LessThanOrEqual ? ']' : ')'); + } + + private static bool TryWriteConstraints(ref Span destination, ref int charsWritten, VersionConstraint[] set, bool bareEquals) + { + var written = 0; + foreach (var constraint in set) + { + if (constraint.Comparator is Comparator.Any) + { + continue; + } + + if (written > 0 && !TryWriteChar(ref destination, ref charsWritten, ' ')) + { + return false; + } + + if (!TryWriteConstraint(ref destination, ref charsWritten, constraint, bareEquals)) + { + return false; + } + + written++; + } + + return written > 0 || TryWriteLiteral(ref destination, ref charsWritten, "*"); + } + + private static bool TryWriteConstraint(ref Span destination, ref int charsWritten, VersionConstraint constraint, bool bareEquals) + { + switch (constraint.Comparator) + { + case Comparator.Equal when bareEquals: + return TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.Equal: + return TryWriteLiteral(ref destination, ref charsWritten, "=") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.GreaterThan: + return TryWriteLiteral(ref destination, ref charsWritten, ">") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.GreaterThanOrEqual: + return TryWriteLiteral(ref destination, ref charsWritten, ">=") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.LessThan: + return TryWriteLiteral(ref destination, ref charsWritten, "<") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.LessThanOrEqual: + return TryWriteLiteral(ref destination, ref charsWritten, "<=") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.NotEqual: + return TryWriteLiteral(ref destination, ref charsWritten, "!=") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + default: + return false; + } + } + + private static bool TryGetShortNpmOperator(VersionConstraint lower, VersionConstraint upper, out char shortOperator) + { + if (lower.Comparator is Comparator.GreaterThanOrEqual && upper.Comparator is Comparator.LessThan) + { + if (upper.Version == GetCaretUpperBound(lower.Version)) + { + shortOperator = '^'; + return true; + } + + if (upper.Version == GetTildeUpperBound(lower.Version)) + { + shortOperator = '~'; + return true; + } + } + + shortOperator = default; + return false; + } + + private static SemanticVersion GetCaretUpperBound(SemanticVersion version) + { + return version.Major > 0 + ? new SemanticVersion(version.Major + 1, 0, 0) + : version.Minor > 0 + ? new SemanticVersion(0, version.Minor + 1, 0) + : new SemanticVersion(0, 0, version.Patch + 1); + } + + private static SemanticVersion GetTildeUpperBound(SemanticVersion version) + { + return new SemanticVersion(version.Major, version.Minor + 1, 0); + } + + private static void AnalyzeSet( + VersionConstraint[] set, + out int effectiveCount, + out bool hasExact, + out VersionConstraint exact, + out bool hasLower, + out VersionConstraint lower, + out bool hasUpper, + out VersionConstraint upper, + out bool hasUnsupported) + { + effectiveCount = 0; + hasExact = false; + exact = default; + hasLower = false; + lower = default; + hasUpper = false; + upper = default; + hasUnsupported = false; + + foreach (var constraint in set) + { + if (constraint.Comparator is Comparator.Any) + { + continue; + } + + effectiveCount++; + switch (constraint.Comparator) + { + case Comparator.Equal: + hasExact = true; + exact = constraint; + break; + case Comparator.GreaterThan: + case Comparator.GreaterThanOrEqual: + hasLower = true; + lower = constraint; + break; + case Comparator.LessThan: + case Comparator.LessThanOrEqual: + hasUpper = true; + upper = constraint; + break; + default: + hasUnsupported = true; + break; + } + } + } + + private static bool TryWriteVersion(ref Span destination, ref int charsWritten, SemanticVersion version) + { + if (!version.TryFormat(destination, out var written, "f", null)) + { + return false; + } + + destination = destination[written..]; + charsWritten += written; + return true; + } + + private static bool TryWriteChar(ref Span destination, ref int charsWritten, char value) + { + if (destination.IsEmpty) + { + return false; + } + + destination[0] = value; + destination = destination[1..]; + charsWritten++; + return true; + } + + private static bool TryWriteLiteral(ref Span destination, ref int charsWritten, ReadOnlySpan value) + { + if (!value.TryCopyTo(destination)) + { + return false; + } + + destination = destination[value.Length..]; + charsWritten += value.Length; + return true; + } + + private static bool TryGetFormat(string? format, out RangeStringFormat rangeFormat) + { + return TryGetFormat(format.AsSpan(), out rangeFormat); + } + + private static bool TryGetFormat(ReadOnlySpan format, out RangeStringFormat rangeFormat) + { + if (format.IsEmpty || format is "ns") + { + rangeFormat = RangeStringFormat.NpmShort; + return true; + } + + if (format is "m") + { + rangeFormat = RangeStringFormat.Maven; + return true; + } + + if (format is "n") + { + rangeFormat = RangeStringFormat.Npm; + return true; + } + + rangeFormat = default; + return false; + } +} + +internal enum RangeStringFormat +{ + Maven, + Npm, + NpmShort +} \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs new file mode 100644 index 0000000..500b111 --- /dev/null +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -0,0 +1,502 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.RegularExpressions; + +namespace Geekeey.SemVer; + +public readonly partial record struct SemanticVersionRange : ISpanParsable +{ + #region IParsable + + /// + /// Parses a string into a . + /// + /// + public static SemanticVersionRange Parse(string s) + { + return Parse(s.AsSpan(), null); + } + + /// + public static SemanticVersionRange Parse(string s, IFormatProvider? provider) + { + return Parse(s.AsSpan(), provider); + } + + /// + /// Tries to parse a string into a . + /// + /// + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersionRange result) + { + return TryParse(s, null, out result); + } + + /// + 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 + + /// + /// Parses a span of characters into a . + /// + public static SemanticVersionRange Parse(ReadOnlySpan s) + { + return Parse(s, null); + } + + /// + public static SemanticVersionRange Parse(ReadOnlySpan s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var range)) + { + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + return range; + } + + /// + /// Tries to parse a span of characters into a . + /// + public static bool TryParse(ReadOnlySpan s, [MaybeNullWhen(false)] out SemanticVersionRange result) + { + return TryParse(s, null, out result); + } + + /// + public static bool TryParse(ReadOnlySpan 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 s, out SemanticVersionRange result) + { + result = default; + + var alternatives = new List(); + var start = 0; + var bracketLevel = 0; + + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + switch (c) + { + case '[' or '(': + bracketLevel++; + break; + + case ']' or ')': + if (--bracketLevel < 0) + { + return false; + } + break; + + case ',' when bracketLevel == 0: + if (!TryParseSingleMavenRange(s[start..i].Trim(), out var constraints)) + { + return false; + } + + alternatives.Add(constraints); + start = i + 1; + break; + } + } + + if (bracketLevel is not 0 || !TryParseSingleMavenRange(s[start..].Trim(), out var lastConstraints)) + { + return false; + } + + alternatives.Add(lastConstraints); + result = new SemanticVersionRange([.. alternatives]); + return true; + } + + private static bool TryParseSingleMavenRange(ReadOnlySpan s, [NotNullWhen(true)] out VersionConstraint[]? constraints) + { + constraints = null; + if (s.IsEmpty) + { + return false; + } + + if (s[0] is not '[' and not '(') + { + if (!TryParseVersionPartially(s, out var version, out _)) + { + return false; + } + + constraints = [new VersionConstraint(Comparator.GreaterThanOrEqual, version)]; + return true; + } + + var last = s.Length - 1; + if (s[last] is not ']' and not ')') + { + return false; + } + + var inclusiveStart = s[0] is '['; + var inclusiveEnd = s[last] is ']'; + var inner = s[1..last]; + var commaIndex = inner.IndexOf(','); + + if (commaIndex < 0) + { + if (!inclusiveStart || !inclusiveEnd || !TryParseVersionPartially(inner.Trim(), out var exactVersion, out _)) + { + return false; + } + + constraints = [new VersionConstraint(Comparator.Equal, exactVersion)]; + return true; + } + + var lowerText = inner[..commaIndex].Trim(); + var upperText = inner[(commaIndex + 1)..].Trim(); + var list = new List(2); + + if (!lowerText.IsEmpty) + { + if (!TryParseVersionPartially(lowerText, out var lowerVersion, out _)) + { + return false; + } + + list.Add(new VersionConstraint(inclusiveStart ? Comparator.GreaterThanOrEqual : Comparator.GreaterThan, lowerVersion)); + } + + if (!upperText.IsEmpty) + { + if (!TryParseVersionPartially(upperText, out var upperVersion, out _)) + { + return false; + } + + list.Add(new VersionConstraint(inclusiveEnd ? Comparator.LessThanOrEqual : Comparator.LessThan, upperVersion)); + } + + constraints = [.. list]; + return true; + } + + private static bool TryParseNode(ReadOnlySpan s, out SemanticVersionRange result) + { + result = default; + var alternatives = new List(); + var start = 0; + + while (start < s.Length) + { + var separator = s[start..].IndexOf("||".AsSpan()); + var end = separator < 0 ? s.Length : start + separator; + var part = s[start..end].Trim(); + + if (!part.IsEmpty) + { + if (!TryParseNpmSet(part, out var constraints)) + { + return false; + } + + alternatives.Add(constraints); + } + + if (separator < 0) + { + break; + } + + start = end + 2; + } + + result = new SemanticVersionRange([.. alternatives]); + return true; + } + + private static bool TryParseNpmSet(ReadOnlySpan s, [NotNullWhen(true)] out VersionConstraint[]? constraints) + { + constraints = null; + var hyphenIndex = s.IndexOf(" - ".AsSpan()); + if (hyphenIndex > 0) + { + var lowerText = s[..hyphenIndex].Trim(); + var upperText = s[(hyphenIndex + 3)..].Trim(); + if (!SemanticVersion.TryParse(lowerText, out var lowerVersion) || !SemanticVersion.TryParse(upperText, out var upperVersion)) + { + return false; + } + + constraints = + [ + new VersionConstraint(Comparator.GreaterThanOrEqual, lowerVersion), + new VersionConstraint(Comparator.LessThanOrEqual, upperVersion) + ]; + return true; + } + + var list = new List(2); + var current = s; + while (TryReadToken(ref current, out var token)) + { + if (IsOperatorOnly(token)) + { + if (!TryReadToken(ref current, out var valueToken) || !TryParseNpmConstraint(token, valueToken, list)) + { + return false; + } + + continue; + } + + if (!TryParseNpmConstraint(default, token, list)) + { + return false; + } + } + + constraints = [.. list]; + return true; + } + + private static bool TryReadToken(ref ReadOnlySpan s, out ReadOnlySpan 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 IsOperatorOnly(ReadOnlySpan s) + { + return s is ">=" or "<=" or ">" or "<" or "~" or "^" or "="; + } + + private static bool TryParseNpmConstraint(ReadOnlySpan op, ReadOnlySpan valueText, List constraints) + { + if (op.IsEmpty) + { + if (valueText.StartsWith(">=")) + { + op = ">="; + valueText = valueText[2..]; + } + else if (valueText.StartsWith("<=")) + { + op = "<="; + valueText = valueText[2..]; + } + else if (valueText.StartsWith(">")) + { + op = ">"; + valueText = valueText[1..]; + } + else if (valueText.StartsWith("<")) + { + op = "<"; + valueText = valueText[1..]; + } + else if (valueText.StartsWith("^")) + { + op = "^"; + valueText = valueText[1..]; + } + else if (valueText.StartsWith("~")) + { + op = "~"; + valueText = valueText[1..]; + } + else if (valueText.StartsWith("=")) + { + op = "="; + valueText = valueText[1..]; + } + } + + valueText = valueText.Trim(); + if (IsWildcard(valueText)) + { + constraints.Add(new VersionConstraint(Comparator.Any, default)); + return true; + } + + if (!TryParseVersionPartially(valueText, out var version, out var components)) + { + return false; + } + + switch (op) + { + case "^": + constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, version)); + constraints.Add(new VersionConstraint(Comparator.LessThan, GetCaretUpperBound(version))); + return true; + case "~": + constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, version)); + constraints.Add(new VersionConstraint(Comparator.LessThan, components >= 2 ? GetTildeUpperBound(version) : new SemanticVersion(version.Major + 1, 0, 0))); + return true; + case ">": + constraints.Add(new VersionConstraint(Comparator.GreaterThan, version)); + return true; + case ">=": + constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, version)); + return true; + case "<": + constraints.Add(new VersionConstraint(Comparator.LessThan, version)); + return true; + case "<=": + constraints.Add(new VersionConstraint(Comparator.LessThanOrEqual, version)); + return true; + } + + switch (components) + { + case 3: + constraints.Add(new VersionConstraint(Comparator.Equal, version)); + return true; + case 2: + constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, version)); + constraints.Add(new VersionConstraint(Comparator.LessThan, new SemanticVersion(version.Major, version.Minor + 1, 0))); + return true; + case 1: + constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, version)); + constraints.Add(new VersionConstraint(Comparator.LessThan, new SemanticVersion(version.Major + 1, 0, 0))); + return true; + default: + return false; + } + } + + private static bool TryParseVersionPartially(ReadOnlySpan s, out SemanticVersion version, out int components) + { + version = default; + components = 0; + s = s.Trim(); + if (s.IsEmpty) + { + return false; + } + + var core = s; + ReadOnlySpan prerelease = default; + ReadOnlySpan metadata = default; + var prereleaseIndex = s.IndexOf('-'); + var metadataIndex = s.IndexOf('+'); + + if (metadataIndex >= 0) + { + core = s[..metadataIndex]; + metadata = s[(metadataIndex + 1)..]; + } + + if (prereleaseIndex >= 0) + { + if (metadataIndex >= 0 && prereleaseIndex > metadataIndex) + { + return false; + } + + core = s[..prereleaseIndex]; + var prereleaseEnd = metadataIndex >= 0 ? metadataIndex : s.Length; + prerelease = s[(prereleaseIndex + 1)..prereleaseEnd]; + } + + ulong major = 0; + ulong minor = 0; + ulong patch = 0; + var segmentStart = 0; + + while (segmentStart <= core.Length) + { + var nextSeparator = core[segmentStart..].IndexOf('.'); + var segmentEnd = nextSeparator < 0 ? core.Length : segmentStart + nextSeparator; + var segment = core[segmentStart..segmentEnd]; + if (segment.IsEmpty) + { + return false; + } + + if (IsWildcard(segment)) + { + version = components switch + { + 1 => new SemanticVersion(major, 0, 0), + 2 => new SemanticVersion(major, minor, 0), + _ => default + }; + return true; + } + + if (!ulong.TryParse(segment, out var value)) + { + return false; + } + + switch (components) + { + case 0: + major = value; + break; + case 1: + minor = value; + break; + case 2: + patch = value; + break; + default: + return false; + } + + components++; + if (nextSeparator < 0) + { + break; + } + + segmentStart = segmentEnd + 1; + } + + version = new SemanticVersion(major, minor, patch, prerelease.IsEmpty ? null : prerelease.ToString(), metadata.IsEmpty ? null : metadata.ToString()); + return true; + } + + private static bool IsWildcard(ReadOnlySpan s) + { + return s is "*" or "x" or "X"; + } +} \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index 2ed0999..e582810 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -1,10 +1,7 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 -using System.Diagnostics.CodeAnalysis; -using System.Text; using System.Text.Json.Serialization; -using System.Text.Unicode; namespace Geekeey.SemVer; @@ -12,15 +9,13 @@ namespace Geekeey.SemVer; /// Represents a semantic version range. /// [JsonConverter(typeof(SemanticVersionRangeJsonConverter))] -public readonly partial record struct SemanticVersionRange : ISpanParsable, IUtf8SpanParsable, ISpanFormattable, IUtf8SpanFormattable +public readonly partial record struct SemanticVersionRange { private readonly VersionConstraint[][]? _sets; - private readonly string? _originalString; - private SemanticVersionRange(VersionConstraint[][] sets, string originalString) + private SemanticVersionRange(VersionConstraint[][] sets) { _sets = sets; - _originalString = originalString; } /// @@ -30,14 +25,25 @@ public readonly partial record struct SemanticVersionRange : ISpanParsabletrue if the version satisfies the range; otherwise, false. public bool Satisfies(SemanticVersion version) { - if (_sets == null || _sets.Length == 0) + if (_sets?.Length is null or 0) { return true; } foreach (var set in _sets) { - if (IsSatisfiedBySet(set, version)) + if (version.Prerelease is not null) + { + if (!set.Any(constraint => constraint.Version.Prerelease is not null && + constraint.Version.Major == version.Major && + constraint.Version.Minor == version.Minor && + constraint.Version.Patch == version.Patch)) + { + continue; + } + } + + if (set.All(constraint => constraint.Satisfies(version))) { return true; } @@ -46,571 +52,49 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable public override string ToString() { - return _originalString ?? "*"; + return ToString(null, null); } - internal int RequiredBufferSize => _originalString?.Length ?? 1; + internal int RequiredBufferSize => GetRequiredBufferSize(RangeStringFormat.NpmShort); - #region IFormattable - - /// - public string ToString(string? format, IFormatProvider? formatProvider) + internal readonly record struct VersionConstraint { - return ToString(); + public VersionConstraint(Comparator comparator, SemanticVersion version) + { + Comparator = comparator; + Version = version; + } + + public Comparator Comparator { get; } + public SemanticVersion Version { get; } + + public bool Satisfies(SemanticVersion version) + { + return Comparator switch + { + Comparator.Any => true, + Comparator.Equal => version.CompareTo(Version) is 0, + Comparator.NotEqual => version.CompareTo(Version) is not 0, + Comparator.GreaterThan => version.CompareTo(Version) > 0, + Comparator.GreaterThanOrEqual => version.CompareTo(Version) >= 0, + Comparator.LessThan => version.CompareTo(Version) < 0, + Comparator.LessThanOrEqual => version.CompareTo(Version) <= 0, + _ => false, + }; + } } - #endregion - - #region ISpanFormattable - - /// - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + internal enum Comparator { - var s = ToString(); - if (s.Length > destination.Length) - { - charsWritten = 0; - return false; - } - - s.AsSpan().CopyTo(destination); - charsWritten = s.Length; - return true; - } - - #endregion - - #region IUtf8SpanFormattable - - /// - public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) - { - return Utf8.TryWrite(utf8Destination, $"{ToString()}", out bytesWritten); - } - - #endregion - - #region IParsable - - /// - public static SemanticVersionRange Parse(string s, IFormatProvider? provider) - { - return Parse(s.AsSpan(), provider); - } - - /// - 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 - - /// - /// Parses a span of characters into a . - /// - public static SemanticVersionRange Parse(ReadOnlySpan s) - { - return Parse(s, null); - } - - /// - public static SemanticVersionRange Parse(ReadOnlySpan s, IFormatProvider? provider) - { - if (!TryParse(s, provider, out var range)) - { - throw new FormatException($"The input string '{s}' was not in a correct format."); - } - - return range; - } - - /// - /// Tries to parse a span of characters into a . - /// - public static bool TryParse(ReadOnlySpan s, [MaybeNullWhen(false)] out SemanticVersionRange result) - { - return TryParse(s, null, out result); - } - - /// - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result) - { - s = s.Trim(); - if (s.IsEmpty) - { - result = new SemanticVersionRange([], "*"); - return true; - } - - return s[0] is '[' or '(' ? TryParseMaven(s, out result) : TryParseNpm(s, out result); - } - - #endregion - - #region IUtf8SpanParsable - - /// - /// Parses a span of UTF-8 bytes into a . - /// - public static SemanticVersionRange Parse(ReadOnlySpan utf8Text) - { - return Parse(utf8Text, null); - } - - /// - public static SemanticVersionRange Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) - { - if (!TryParse(utf8Text, provider, out var range)) - { - throw new FormatException($"The input string '{Encoding.UTF8.GetString(utf8Text)}' was not in a correct format."); - } - - return range; - } - - /// - public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result) - { - if (utf8Text.IsEmpty) - { - result = new SemanticVersionRange([], "*"); - return true; - } - - var charCount = Encoding.UTF8.GetCharCount(utf8Text); - var chars = charCount <= 256 ? stackalloc char[charCount] : new char[charCount]; - Encoding.UTF8.GetChars(utf8Text, chars); - - return TryParse(chars, provider, out result); - } - - #endregion - - private static bool TryParseMaven(ReadOnlySpan s, out SemanticVersionRange result) - { - result = default; - var original = s.ToString(); - var alternatives = new List(); - - var start = 0; - var bracketLevel = 0; - for (var i = 0; i < s.Length; i++) - { - if (s[i] is '[' or '(') - { - bracketLevel++; - } - else if (s[i] is ']' or ')') - { - bracketLevel--; - } - else if (s[i] is ',' && bracketLevel == 0) - { - if (!TryParseSingleMavenRange(s[start..i].Trim(), out var constraints)) - { - return false; - } - - alternatives.Add(constraints); - start = i + 1; - } - } - - if (!TryParseSingleMavenRange(s[start..].Trim(), out var lastConstraints)) - { - return false; - } - - alternatives.Add(lastConstraints); - - result = new SemanticVersionRange([.. alternatives], original); - return true; - } - - private static bool TryParseSingleMavenRange(ReadOnlySpan s, [NotNullWhen(true)] out VersionConstraint[]? constraints) - { - constraints = null; - if (s.IsEmpty) - { - return false; - } - - if (s[0] is not '[' and not '(') - { - if (!TryParseVersionPartially(s, out var v, out _)) - { - return false; - } - - constraints = [new VersionConstraint(Comparator.GreaterThanOrEqual, v)]; - return true; - } - - var last = s.Length - 1; - if (s[last] is not ']' and not ')') - { - return false; - } - - var inclusiveStart = s[0] == '['; - var inclusiveEnd = s[last] == ']'; - var inner = s[1..last]; - var commaIndex = inner.IndexOf(','); - - if (commaIndex < 0) - { - if (!inclusiveStart || !inclusiveEnd) - { - return false; - } - - if (!TryParseVersionPartially(inner, out var v, out _)) - { - return false; - } - - constraints = [new VersionConstraint(Comparator.Equal, v)]; - return true; - } - - var startStr = inner[..commaIndex].Trim(); - var endStr = inner[(commaIndex + 1)..].Trim(); - var list = new List(); - - if (!startStr.IsEmpty) - { - if (!TryParseVersionPartially(startStr, out var vStart, out _)) - { - return false; - } - - list.Add(new VersionConstraint(inclusiveStart ? Comparator.GreaterThanOrEqual : Comparator.GreaterThan, vStart)); - } - - if (!endStr.IsEmpty) - { - if (!TryParseVersionPartially(endStr, out var vEnd, out _)) - { - return false; - } - - list.Add(new VersionConstraint(inclusiveEnd ? Comparator.LessThanOrEqual : Comparator.LessThan, vEnd)); - } - - constraints = [.. list]; - return true; - } - - private static bool TryParseNpm(ReadOnlySpan s, out SemanticVersionRange result) - { - result = default; - var original = s.ToString(); - var alternatives = new List(); - - var start = 0; - while (start < s.Length) - { - var orIndex = s[start..].IndexOf("||".AsSpan()); - var part = orIndex < 0 ? s[start..].Trim() : s[start..(start + orIndex)].Trim(); - start = orIndex < 0 ? s.Length : start + orIndex + 2; - - if (part.IsEmpty) - { - continue; - } - - if (!TryParseNpmSet(part, out var constraints)) - { - return false; - } - - alternatives.Add(constraints); - } - - result = new SemanticVersionRange([.. alternatives], original); - return true; - } - - private static bool TryParseNpmSet(ReadOnlySpan s, [NotNullWhen(true)] out VersionConstraint[]? constraints) - { - constraints = null; - var list = new List(); - - var hyphenIndex = s.IndexOf(" - ".AsSpan()); - if (hyphenIndex > 0) - { - var v1Str = s[..hyphenIndex].Trim(); - var v2Str = s[(hyphenIndex + 3)..].Trim(); - if (!SemanticVersion.TryParse(v1Str, out var v1) || !SemanticVersion.TryParse(v2Str, out var v2)) - { - return false; - } - - constraints = - [ - new VersionConstraint(Comparator.GreaterThanOrEqual, v1), - new VersionConstraint(Comparator.LessThanOrEqual, v2) - ]; - return true; - } - - var current = s; - while (!current.IsEmpty) - { - current = current.TrimStart(); - if (current.IsEmpty) - { - break; - } - - var spaceIndex = current.IndexOf(' '); - var part = spaceIndex < 0 ? current : current[..spaceIndex]; - current = spaceIndex < 0 ? default : current[spaceIndex..]; - - if (IsOperatorOnly(part)) - { - current = current.TrimStart(); - var nextSpace = current.IndexOf(' '); - var nextPart = nextSpace < 0 ? current : current[..nextSpace]; - current = nextSpace < 0 ? default : current[nextSpace..]; - - if (!TryParseNpmConstraint(part, nextPart, list)) - { - return false; - } - } - else - { - if (!TryParseNpmConstraint(default, part, list)) - { - return false; - } - } - } - - constraints = [.. list]; - return true; - } - - private static bool IsOperatorOnly(ReadOnlySpan s) - { - return s is ">=" or "<=" or ">" or "<" or "~" or "^" or "="; - } - - private static bool TryParseNpmConstraint(ReadOnlySpan op, ReadOnlySpan vStr, List constraints) - { - if (op.IsEmpty) - { - if (vStr.StartsWith(">=")) - { - op = ">="; - vStr = vStr[2..]; - } - else if (vStr.StartsWith("<=")) - { - op = "<="; - vStr = vStr[2..]; - } - else if (vStr.StartsWith(">")) - { - op = ">"; - vStr = vStr[1..]; - } - else if (vStr.StartsWith("<")) - { - op = "<"; - vStr = vStr[1..]; - } - else if (vStr.StartsWith("^")) - { - op = "^"; - vStr = vStr[1..]; - } - else if (vStr.StartsWith("~")) - { - op = "~"; - vStr = vStr[1..]; - } - else if (vStr.StartsWith("=")) - { - op = "="; - vStr = vStr[1..]; - } - } - - vStr = vStr.Trim(); - if (vStr is "*" or "x" or "X") - { - constraints.Add(new VersionConstraint(Comparator.Any, default)); - return true; - } - - if (!TryParseVersionPartially(vStr, out var v, out var components)) - { - return false; - } - - if (op is "^") - { - constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v)); - var endV = v.Major > 0 ? new SemanticVersion(v.Major + 1, 0, 0) : (v.Minor > 0 ? new SemanticVersion(0, v.Minor + 1, 0) : new SemanticVersion(0, 0, v.Patch + 1)); - constraints.Add(new VersionConstraint(Comparator.LessThan, endV)); - } - else if (op is "~") - { - constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v)); - var endV = components >= 2 ? new SemanticVersion(v.Major, v.Minor + 1, 0) : new SemanticVersion(v.Major + 1, 0, 0); - constraints.Add(new VersionConstraint(Comparator.LessThan, endV)); - } - else if (op is ">") constraints.Add(new VersionConstraint(Comparator.GreaterThan, v)); - else if (op is ">=") constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v)); - else if (op is "<") constraints.Add(new VersionConstraint(Comparator.LessThan, v)); - else if (op is "<=") constraints.Add(new VersionConstraint(Comparator.LessThanOrEqual, v)); - else - { - if (components == 3) - { - constraints.Add(new VersionConstraint(Comparator.Equal, v)); - } - else if (components == 2) - { - constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v)); - constraints.Add(new VersionConstraint(Comparator.LessThan, new SemanticVersion(v.Major, v.Minor + 1, 0))); - } - else if (components == 1) - { - constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v)); - constraints.Add(new VersionConstraint(Comparator.LessThan, new SemanticVersion(v.Major + 1, 0, 0))); - } - } - - return true; - } - - private static bool TryParseVersionPartially(ReadOnlySpan s, out SemanticVersion version, out int components) - { - version = default; - components = 0; - - var dashIndex = s.IndexOf('-'); - var metadataIndex = s.IndexOf('+'); - var suffixIndex = dashIndex >= 0 ? (metadataIndex >= 0 ? Math.Min(dashIndex, metadataIndex) : dashIndex) : metadataIndex; - - var versionPart = suffixIndex >= 0 ? s[..suffixIndex] : s; - var prerelease = dashIndex >= 0 ? (metadataIndex > dashIndex ? s[(dashIndex + 1)..metadataIndex].ToString() : s[(dashIndex + 1)..].ToString()) : null; - var metadata = metadataIndex >= 0 ? s[(metadataIndex + 1)..].ToString() : null; - - ulong major = 0, minor = 0, patch = 0; - var current = versionPart; - var componentCount = 0; - - while (!current.IsEmpty && componentCount < 3) - { - var dotIndex = current.IndexOf('.'); - var part = dotIndex < 0 ? current : current[..dotIndex]; - - if (part is "*" or "x" or "X") - { - components = componentCount; - version = componentCount switch - { - 1 => new SemanticVersion(major, 0, 0), - 2 => new SemanticVersion(major, minor, 0), - _ => default - }; - return true; - } - - if (!ulong.TryParse(part, out var value)) - { - return false; - } - - if (componentCount == 0) major = value; - else if (componentCount == 1) minor = value; - else if (componentCount == 2) patch = value; - - componentCount++; - if (dotIndex < 0) break; - current = current[(dotIndex + 1)..]; - } - - components = componentCount; - version = new SemanticVersion(major, minor, patch, prerelease, metadata); - return true; - } -} - -internal enum Comparator -{ - Any, - Equal, - GreaterThan, - GreaterThanOrEqual, - LessThan, - LessThanOrEqual, - NotEqual -} - -internal readonly record struct VersionConstraint(Comparator Comparator, SemanticVersion Version) -{ - public bool Satisfies(SemanticVersion version) - { - if (Comparator is Comparator.Any) - { - return true; - } - - var cmp = version.CompareTo(Version); - return Comparator switch - { - Comparator.Equal => cmp == 0, - Comparator.NotEqual => cmp != 0, - Comparator.GreaterThan => cmp > 0, - Comparator.GreaterThanOrEqual => cmp >= 0, - Comparator.LessThan => cmp < 0, - Comparator.LessThanOrEqual => cmp <= 0, - _ => false - }; + Any, + Equal, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual, + NotEqual } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRangeJsonConverter.cs b/src/semver/SemanticVersionRangeJsonConverter.cs index b810f26..b56fc3d 100644 --- a/src/semver/SemanticVersionRangeJsonConverter.cs +++ b/src/semver/SemanticVersionRangeJsonConverter.cs @@ -1,6 +1,7 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Buffers; using System.Text.Json; using System.Text.Json.Serialization; @@ -8,6 +9,8 @@ namespace Geekeey.SemVer; internal sealed class SemanticVersionRangeJsonConverter : JsonConverter { + private const int StackBufferThreshold = 256; + public override SemanticVersionRange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.Null) @@ -22,16 +25,35 @@ internal sealed class SemanticVersionRangeJsonConverter : JsonConverter StackBufferThreshold ? ArrayPool.Shared.Rent(capacity) : null; + try + { + scoped var buffer = shared.AsSpan(); + if (shared is null) + { + buffer = stackalloc char[capacity]; + } + + _ = value.TryFormat(buffer, out var bytesWritten, default, null); + writer.WriteStringValue(buffer[..bytesWritten]); + } + finally + { + if (shared is not null) + { + ArrayPool.Shared.Return(shared); + } + } } } \ No newline at end of file