diff --git a/src/semver/SemanticVersion.Formatting.cs b/src/semver/SemanticVersion.Formatting.cs index c6a0992..f6b23d9 100644 --- a/src/semver/SemanticVersion.Formatting.cs +++ b/src/semver/SemanticVersion.Formatting.cs @@ -1,56 +1,29 @@ -using System.Buffers; -using System.Globalization; +using System.Runtime.CompilerServices; 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] - /// + /// + /// + /// 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 + if (format is not null and not "s" and not "f" and not "r") { - 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); - } + 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 @@ -67,47 +40,46 @@ public readonly partial record struct SemanticVersion : ISpanFormattable } /// + /// + /// + /// 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 bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { - if (!destination.TryWrite(NumberFormatInfo.InvariantInfo, $"{Major}.{Minor}.{Patch}", out charsWritten)) + if (format.IsEmpty) + { + format = "s"; + } + + if (format is not "s" and not "f" and not "r") { charsWritten = 0; return false; } - destination = destination[charsWritten..]; + var builder = new SpanStringBuilder(destination, provider); + builder.AppendFormatted(Major); + builder.AppendLiteral("."); + builder.AppendFormatted(Minor); + builder.AppendLiteral("."); + builder.AppendFormatted(Patch); 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; + builder.AppendLiteral("-"); + builder.AppendFormatted(Prerelease); } 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; + builder.AppendLiteral("+"); + builder.AppendFormatted(Metadata); } - _ = destination; - - return true; + return builder.TryComplete(out charsWritten); } #endregion diff --git a/src/semver/SemanticVersion.JsonConverter.cs b/src/semver/SemanticVersion.JsonConverter.cs new file mode 100644 index 0000000..d08c40a --- /dev/null +++ b/src/semver/SemanticVersion.JsonConverter.cs @@ -0,0 +1,41 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Geekeey.SemVer; + +[JsonConverter(typeof(SemanticVersionJsonConverter))] +public readonly partial record struct SemanticVersion +{ + internal sealed class SemanticVersionJsonConverter : JsonConverter + { + public override SemanticVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + return default; + } + + if (reader.TokenType is not JsonTokenType.String || reader.GetString() is not { } value) + { + throw new JsonException("Expected string"); + } + + try + { + return Parse(value); + } + catch (FormatException exception) + { + throw new JsonException(exception.Message, exception); + } + } + + public override void Write(Utf8JsonWriter writer, SemanticVersion value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString("f", null)); + } + } +} \ No newline at end of file diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs index fc3a0cf..f53dbcb 100644 --- a/src/semver/SemanticVersion.Parsing.cs +++ b/src/semver/SemanticVersion.Parsing.cs @@ -53,12 +53,12 @@ public readonly partial record struct SemanticVersion : ISpanParsable public static SemanticVersion Parse(ReadOnlySpan s, IFormatProvider? provider) { - if (!TryParse(s, provider, out var version)) + if (!TryParse(s, provider, out var result)) { throw new FormatException($"The input string '{s}' was not in a correct format."); } - return version; + return result; } /// @@ -73,60 +73,76 @@ public readonly partial record struct SemanticVersion : ISpanParsable public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result) { - result = default; + return TryParsePartially(s, out result, out var components) && components is 3; + } + + #endregion + + internal static bool TryParsePartially(ReadOnlySpan s, out SemanticVersion version, out int components) + { + version = default; + components = 0; + if (s.IsEmpty) { return false; } var metadata = default(string); - var plusIndex = s.IndexOf('+'); - if (plusIndex >= 0) + if (s.IndexOf('+') is >= 0 and var metadataIndex) { - metadata = s[(plusIndex + 1)..].ToString(); - s = s[..plusIndex]; + if (s[(metadataIndex + 1)..] is not { IsEmpty: false } value) + { + return false; + } + + metadata = new string(value); + s = s[..metadataIndex]; } var prerelease = default(string); - var dashIndex = s.IndexOf('-'); - if (dashIndex >= 0) + if (s.IndexOf('-') is >= 0 and var prereleaseIndex) { - prerelease = s[(dashIndex + 1)..].ToString(); - s = s[..dashIndex]; + if (s[(prereleaseIndex + 1)..] is not { IsEmpty: false } value) + { + return false; + } + + prerelease = new string(value); + s = s[..prereleaseIndex]; } - var firstDot = s.IndexOf('.'); - if (firstDot < 0) + Span destination = stackalloc Range[3]; + Span component = stackalloc ulong[3]; + + foreach (var range in destination[..s.Split(destination, '.')]) { - return false; + if (s[range] is not { IsEmpty: false } segment) + { + return false; + } + + if (segment is "*" or "x" or "X") + { + version = components switch + { + 0 => default, + 1 => new SemanticVersion(component[0], 0, 0), + 2 => new SemanticVersion(component[0], component[1], 0), + _ => throw new InvalidOperationException(), + }; + return true; + } + + if (!ulong.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out var value)) + { + return false; + } + + component[components++] = value; } - 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); + version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata); return true; } - - #endregion } \ No newline at end of file diff --git a/src/semver/SemanticVersion.cs b/src/semver/SemanticVersion.cs index a2a9de5..84a7ed5 100644 --- a/src/semver/SemanticVersion.cs +++ b/src/semver/SemanticVersion.cs @@ -1,11 +1,8 @@ -using System.Text.Json.Serialization; - namespace Geekeey.SemVer; /// /// Represents a semantic version that adheres to the Semantic Versioning 2.0.0 specification. /// -[JsonConverter(typeof(SemanticVersionJsonConverter))] public readonly partial record struct SemanticVersion { /// @@ -111,4 +108,4 @@ public readonly partial record struct SemanticVersion { return ToString(null, null); } -} +} \ No newline at end of file diff --git a/src/semver/SemanticVersionJsonConverter.cs b/src/semver/SemanticVersionJsonConverter.cs deleted file mode 100644 index 4921d00..0000000 --- a/src/semver/SemanticVersionJsonConverter.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using System.Buffers; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Geekeey.SemVer; - -internal sealed class SemanticVersionJsonConverter : JsonConverter -{ - private const int StackBufferThreshold = 256; - - public override SemanticVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType is JsonTokenType.Null) - { - return default; - } - - if (reader.TokenType is not JsonTokenType.String) - { - throw new JsonException("Expected string"); - } - - try - { - return SemanticVersion.Parse(reader.GetString().AsSpan()); - } - catch (FormatException e) - { - throw new JsonException(e.Message, e); - } - } - - public override void Write(Utf8JsonWriter writer, SemanticVersion value, JsonSerializerOptions options) - { - var capacity = value.RequiredBufferSize; - var shared = capacity > 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, "f", null); - - writer.WriteStringValue(buffer[..bytesWritten]); - } - finally - { - if (shared is not null) - { - ArrayPool.Shared.Return(shared); - } - } - } -} \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs deleted file mode 100644 index f41b546..0000000 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ /dev/null @@ -1,465 +0,0 @@ -// 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.JsonConverter.cs b/src/semver/SemanticVersionRange.JsonConverter.cs new file mode 100644 index 0000000..85a2fb0 --- /dev/null +++ b/src/semver/SemanticVersionRange.JsonConverter.cs @@ -0,0 +1,41 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Geekeey.SemVer; + +[JsonConverter(typeof(SemanticVersionRangeJsonConverter))] +public readonly partial record struct SemanticVersionRange +{ + internal sealed class SemanticVersionRangeJsonConverter : JsonConverter + { + public override SemanticVersionRange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + return default; + } + + if (reader.TokenType is not JsonTokenType.String || reader.GetString() is not { } value) + { + throw new JsonException("Expected string"); + } + + try + { + return Parse(value); + } + catch (FormatException exception) + { + throw new JsonException(exception.Message, exception); + } + } + + public override void Write(Utf8JsonWriter writer, SemanticVersionRange value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } +} \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs deleted file mode 100644 index 500b111..0000000 --- a/src/semver/SemanticVersionRange.Parsing.cs +++ /dev/null @@ -1,502 +0,0 @@ -// 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 e582810..20df5c2 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -1,36 +1,29 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 -using System.Text.Json.Serialization; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; namespace Geekeey.SemVer; -/// -/// Represents a semantic version range. -/// -[JsonConverter(typeof(SemanticVersionRangeJsonConverter))] public readonly partial record struct SemanticVersionRange { - private readonly VersionConstraint[][]? _sets; + private readonly ImmutableArray> _constraints; - private SemanticVersionRange(VersionConstraint[][] sets) + private SemanticVersionRange(ImmutableArray> constraints) { - _sets = sets; + _constraints = constraints; } - /// - /// Determines whether the specified version satisfies the range. - /// - /// The version to check. - /// true if the version satisfies the range; otherwise, false. public bool Satisfies(SemanticVersion version) { - if (_sets?.Length is null or 0) + if (_constraints.Length is 0) { return true; } - foreach (var set in _sets) + foreach (var set in _constraints) { if (version.Prerelease is not null) { @@ -43,7 +36,17 @@ public readonly partial record struct SemanticVersionRange } } - if (set.All(constraint => constraint.Satisfies(version))) + if (set.All(constraint => constraint.Comparator switch + { + Comparator.Any => true, + Comparator.Equal => version.CompareTo(constraint.Version) is 0, + Comparator.NotEqual => version.CompareTo(constraint.Version) is not 0, + Comparator.Greater => version.CompareTo(constraint.Version) > 0, + Comparator.GreaterOrEqual => version.CompareTo(constraint.Version) >= 0, + Comparator.Less => version.CompareTo(constraint.Version) < 0, + Comparator.LessOrEqual => version.CompareTo(constraint.Version) <= 0, + _ => false, + })) { return true; } @@ -58,43 +61,818 @@ public readonly partial record struct SemanticVersionRange return ToString(null, null); } - internal int RequiredBufferSize => GetRequiredBufferSize(RangeStringFormat.NpmShort); - - internal readonly record struct VersionConstraint + private readonly struct Constraint { - public VersionConstraint(Comparator comparator, SemanticVersion version) + public Constraint(Comparator comparator, SemanticVersion version) { Comparator = comparator; Version = version; } public Comparator Comparator { get; } - public SemanticVersion Version { get; } - public bool Satisfies(SemanticVersion version) + public SemanticVersion Version { get; } + } + + private enum Comparator + { + Any, + Equal, + Greater, + GreaterOrEqual, + Less, + LessOrEqual, + NotEqual, + } +} + +public readonly partial record struct SemanticVersionRange : ISpanFormattable +{ + #region IFormattable + + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + var handler = new DefaultInterpolatedStringHandler(0, 1, formatProvider); + handler.AppendFormatted(this, format); + return handler.ToStringAndClear(); + } + + #endregion + + #region ISpanFormattable + + /// + /// Tries to format the semantic version range 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) + { + return TryFormatCore(destination, out charsWritten, format); + } + + #endregion + + private bool TryFormatCore(Span destination, out int charsWritten, ReadOnlySpan format) + { + charsWritten = 0; + + if (_constraints is not { Length: > 0 } sets) { - return Comparator switch + 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 ? "," : " || ")) { - 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, + charsWritten = 0; + return false; + } + + if (!TryWriteSet(ref destination, ref charsWritten, sets[i], format)) + { + charsWritten = 0; + return false; + } + } + + return true; + } + + private static bool TryWriteSet(ref Span destination, ref int charsWritten, ImmutableArray 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, Constraint lower, bool hasUpper, Constraint upper) + { + if (!TryWriteChar(ref destination, ref charsWritten, hasLower && lower.Comparator is Comparator.GreaterOrEqual ? '[' : '(')) + { + 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.LessOrEqual ? ']' : ')'); + } + + private static bool TryWriteConstraints(ref Span destination, ref int charsWritten, ImmutableArray 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, Constraint 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.Greater: + return TryWriteLiteral(ref destination, ref charsWritten, ">") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.GreaterOrEqual: + return TryWriteLiteral(ref destination, ref charsWritten, ">=") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.Less: + return TryWriteLiteral(ref destination, ref charsWritten, "<") && + TryWriteVersion(ref destination, ref charsWritten, constraint.Version); + case Comparator.LessOrEqual: + 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(Constraint lower, Constraint upper, out char shortOperator) + { + if (lower.Comparator is Comparator.GreaterOrEqual && upper.Comparator is Comparator.Less) + { + 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( + ImmutableArray set, + out int effectiveCount, + out bool hasExact, + out Constraint exact, + out bool hasLower, + out Constraint lower, + out bool hasUpper, + out Constraint 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.Greater: + case Comparator.GreaterOrEqual: + hasLower = true; + lower = constraint; + break; + case Comparator.Less: + case Comparator.LessOrEqual: + 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; + } +} + +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 result)) + { + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + return result; + } + + /// + /// 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) + { + var sets = new List>(); + + foreach (var range in new JavaSetGrouping(s)) + { + if (s[range] is not { IsEmpty: false } part) + { + continue; + } + + if (!TryParseJavaSet(part, out var constraints)) + { + result = default; + return false; + } + + sets.Add(constraints); + } + + result = new SemanticVersionRange([..sets]); + return true; + } + + private static bool TryParseJavaSet(ReadOnlySpan s, [NotNullWhen(true)] out ImmutableArray constraints) + { + constraints = []; + + if (s.IsEmpty) + { + return false; + } + + if (s is not ['[' or '(', .., ']' or ')']) + { + if (!SemanticVersion.TryParsePartially(s, out var version, out _)) + { + return false; + } + + constraints = [new Constraint(Comparator.GreaterOrEqual, version)]; + 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; + } + + constraints = [new Constraint(Comparator.Equal, version)]; + return true; + } + + var result = new List(2); + + var lowerComparator = inclusiveStart ? Comparator.GreaterOrEqual : Comparator.Greater; + if (!TryAddConstraint(text[..comma].Trim(), lowerComparator, result)) + { + return false; + } + + var upperComparator = inclusiveEnd ? Comparator.LessOrEqual : Comparator.Less; + if (!TryAddConstraint(text[(comma + 1)..].Trim(), upperComparator, result)) + { + return false; + } + + constraints = [.. result]; + return true; + + static bool TryAddConstraint(ReadOnlySpan text, Comparator comparator, ICollection result) + { + if (text.IsEmpty) + { + return true; + } + + if (!SemanticVersion.TryParsePartially(text, out var version, out _)) + { + return false; + } + + result.Add(new Constraint(comparator, version)); + return true; + } + } + + private ref struct JavaSetGrouping + { + private readonly ReadOnlySpan _span; + private int _currentStart; + private int _currentEnd; + + public JavaSetGrouping(ReadOnlySpan 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 s, out SemanticVersionRange result) + { + var sets = new List>(); + + foreach (var range in new NodeSetGrouping(s)) + { + if (s[range] is not { IsEmpty: false } part) + { + continue; + } + + if (!TryParseNodeSet(part, out var constraints)) + { + result = default; + return false; + } + + sets.Add(constraints); + } + + result = new SemanticVersionRange([..sets]); + return true; + } + + private static bool TryParseNodeSet(ReadOnlySpan s, [NotNullWhen(true)] out ImmutableArray constraints) + { + if (s.IndexOf(" - ") is not -1 and var hyphen) + { + if (!SemanticVersion.TryParse(s[..hyphen].Trim(), out var lowerVersion)) + { + constraints = default; + return false; + } + + if (!SemanticVersion.TryParse(s[(hyphen + 3)..].Trim(), out var upperVersion)) + { + constraints = default; + return false; + } + + constraints = + [ + new Constraint(Comparator.GreaterOrEqual, lowerVersion), + new Constraint(Comparator.LessOrEqual, upperVersion), + ]; + return true; + } + + var list = new List(2); + var current = s; + while (TryReadToken(ref current, out var token)) + { + scoped ReadOnlySpan instruction = []; + + if (token is ">=" or "<=" or ">" or "<" or "~" or "^" or "=") + { + instruction = token; + + if (!TryReadToken(ref current, out token)) + { + constraints = 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[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") + { + constraints = [new Constraint(Comparator.Any, default)]; + return true; + } + + if (!TryParseNpmConstraint(instruction, token.Trim(), list)) + { + constraints = default; + 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 TryParseNpmConstraint(ReadOnlySpan op, ReadOnlySpan s, ICollection constraints) + { + if (!SemanticVersion.TryParsePartially(s.Trim(), out var version, out var components)) + { + return false; + } + + switch (op) + { + case "^": + constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); + constraints.Add(new Constraint(Comparator.Less, GetCaretUpperBound(version, components))); + return true; + case "~": + constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); + constraints.Add(new Constraint(Comparator.Less, GetTildeUpperBound(version, components))); + 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; + default: + switch (components) + { + case 3: + constraints.Add(new Constraint(Comparator.Equal, version)); + return true; + case 2: + constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); + constraints.Add(new Constraint(Comparator.Less, new SemanticVersion(version.Major, version.Minor + 1, 0))); + return true; + case 1: + constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); + constraints.Add(new Constraint(Comparator.Less, new SemanticVersion(version.Major + 1, 0, 0))); + return true; + default: + return false; + } + } + + static SemanticVersion GetCaretUpperBound(SemanticVersion version, int components) + { + return components switch + { + 1 => new SemanticVersion(version.Major + 1, 0, 0), + 2 when version.Major is 0 => new SemanticVersion(0, version.Minor + 1, 0), + 2 => new SemanticVersion(version.Major + 1, 0, 0), + _ when version.Major > 0 => new SemanticVersion(version.Major + 1, 0, 0), + _ when version.Minor > 0 => new SemanticVersion(0, version.Minor + 1, 0), + _ => new SemanticVersion(0, 0, version.Patch + 1), + }; + } + + static SemanticVersion GetTildeUpperBound(SemanticVersion version, int components) + { + return components switch + { + >= 2 => new SemanticVersion(version.Major, version.Minor + 1, 0), + _ => new SemanticVersion(version.Major + 1, 0, 0), }; } } - internal enum Comparator + private ref struct NodeSetGrouping { - Any, - Equal, - GreaterThan, - GreaterThanOrEqual, - LessThan, - LessThanOrEqual, - NotEqual + private readonly ReadOnlySpan _span; + private int _currentStart; + private int _currentEnd; + + public NodeSetGrouping(ReadOnlySpan 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; + } } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRangeJsonConverter.cs b/src/semver/SemanticVersionRangeJsonConverter.cs deleted file mode 100644 index b56fc3d..0000000 --- a/src/semver/SemanticVersionRangeJsonConverter.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using System.Buffers; -using System.Text.Json; -using System.Text.Json.Serialization; - -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) - { - return default; - } - - if (reader.TokenType is not JsonTokenType.String) - { - throw new JsonException("Expected string"); - } - - try - { - return SemanticVersionRange.Parse(reader.GetString().AsSpan()); - } - catch (FormatException exception) - { - throw new JsonException(exception.Message, exception); - } - } - - public override void Write(Utf8JsonWriter writer, SemanticVersionRange value, JsonSerializerOptions options) - { - var capacity = value.RequiredBufferSize; - var shared = capacity > 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 diff --git a/src/semver/SpanStringBuilder.cs b/src/semver/SpanStringBuilder.cs new file mode 100644 index 0000000..66f9afa --- /dev/null +++ b/src/semver/SpanStringBuilder.cs @@ -0,0 +1,82 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.SemVer; + +internal ref struct SpanStringBuilder +{ + private Span _destination; + private readonly IFormatProvider? _provider; + + public SpanStringBuilder(Span destination, IFormatProvider? provider = null) + { + _destination = destination; + _provider = provider; + CharsWritten = 0; + Success = true; + } + + public int CharsWritten { get; private set; } + + public bool Success { get; private set; } + + public readonly bool TryComplete(out int charsWritten) + { + charsWritten = Success ? CharsWritten : 0; + return Success; + } + + public void AppendLiteral(string? value) + { + AppendLiteral(value.AsSpan()); + } + + public void AppendLiteral(ReadOnlySpan value) + { + if (!Success) + { + return; + } + + if (!value.TryCopyTo(_destination)) + { + Success = false; + return; + } + + _destination = _destination[value.Length..]; + CharsWritten += value.Length; + } + + public void AppendFormatted(string? value) + { + AppendLiteral(value.AsSpan()); + } + + public void AppendFormatted(ReadOnlySpan value) + { + AppendLiteral(value); + } + + public void AppendFormatted(T value) where T : ISpanFormattable + { + AppendFormatted(value, null); + } + + public void AppendFormatted(T value, string? format) where T : ISpanFormattable + { + if (!Success) + { + return; + } + + if (!value.TryFormat(_destination, out var written, format, _provider)) + { + Success = false; + return; + } + + _destination = _destination[written..]; + CharsWritten += written; + } +} \ No newline at end of file diff --git a/src/semver/_internal/FormattingHelpers.cs b/src/semver/_internal/FormattingHelpers.cs deleted file mode 100644 index d4a6be9..0000000 --- a/src/semver/_internal/FormattingHelpers.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -namespace Geekeey.SemVer; - -// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Buffers/Text/FormattingHelpers.CountDigits.cs -internal static class FormattingHelpers -{ - extension(ulong) - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int CountDigits(ulong value) - { - // Map the log2(value) to a power of 10. - ReadOnlySpan log2ToPow10 = - [ - 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, - 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 10, - 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 13, 14, 14, 14, 15, 15, - 15, 16, 16, 16, 16, 17, 17, 17, 18, 18, 18, 19, 19, 19, 19, 20 - ]; - Debug.Assert(log2ToPow10.Length == 64); - - nint elementOffset = log2ToPow10[(int)ulong.Log2(value)]; - - // Read the associated power of 10. - ReadOnlySpan powersOf10 = - [ - 0, // unused entry to avoid needing to subtract - 0, - 10, - 100, - 1000, - 10000, - 100000, - 1000000, - 10000000, - 100000000, - 1000000000, - 10000000000, - 100000000000, - 1000000000000, - 10000000000000, - 100000000000000, - 1000000000000000, - 10000000000000000, - 100000000000000000, - 1000000000000000000, - 10000000000000000000, - ]; - Debug.Assert((elementOffset + 1) <= powersOf10.Length); - var powerOf10 = Unsafe.Add(ref MemoryMarshal.GetReference(powersOf10), elementOffset); - - // Return the number of digits based on the power of 10, shifted by 1 - // if it falls below the threshold. - var index = (int)elementOffset; - return index - (value < powerOf10 ? 1 : 0); - } - } - - extension(uint) - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int CountDigits(uint value) - { - ReadOnlySpan table = - [ - 4294967296, - 8589934582, - 8589934582, - 8589934582, - 12884901788, - 12884901788, - 12884901788, - 17179868184, - 17179868184, - 17179868184, - 21474826480, - 21474826480, - 21474826480, - 21474826480, - 25769703776, - 25769703776, - 25769703776, - 30063771072, - 30063771072, - 30063771072, - 34349738368, - 34349738368, - 34349738368, - 34349738368, - 38554705664, - 38554705664, - 38554705664, - 41949672960, - 41949672960, - 41949672960, - 42949672960, - 42949672960, - ]; - Debug.Assert(table.Length == 32, "Every result of uint.Log2(value) needs a long entry in the table."); - - var tableValue = table[(int)uint.Log2(value)]; - return (int)((value + tableValue) >> 32); - } - } -} \ No newline at end of file