feat: introduce IdentifierList for prerelease and build-metadata segments
All checks were successful
default / dotnet-default-workflow (push) Successful in 58s
All checks were successful
default / dotnet-default-workflow (push) Successful in 58s
Add a value type wrapping the dot-separated prerelease and build-metadata identifiers, replacing the previous nullable string properties on SemanticVersion. Implements IReadOnlyList<string>, zero-alloc ReadOnlySpan<char> enumeration, Ordinal equality with implicit string conversions, a JSON converter, and ISpanParsable/IParsable parsing validated via IdentifierListFormatInfo. Closes #1
This commit is contained in:
parent
71353363f2
commit
50e15afb4f
9 changed files with 855 additions and 125 deletions
|
|
@ -17,13 +17,19 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|||
- `SemanticVersion` and `SemanticVersionRange` can now be used as `System.Text.Json`
|
||||
dictionary keys. The custom converters override `ReadAsPropertyName`/`WriteAsPropertyName`
|
||||
so `Dictionary<TKey, TValue>` round-trips correctly.
|
||||
- New `IdentifierList` value type for prerelease/build-metadata identifiers.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking:** `SemanticVersion.Prerelease` and `SemanticVersion.Metadata` are now `IdentifierList`
|
||||
instead of `string?`. Implicit `string` conversions keep most call sites compiling, but the property
|
||||
type change is breaking and requires a recompile. Constructors additionally accept `IdentifierList`.
|
||||
- `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules.
|
||||
- `SemanticVersionRange` equality (`==`, `Equals`, `GetHashCode`) is now structural and based
|
||||
on the constraint sets, so ranges that parse to the same comparators compare as equal
|
||||
(previously it was reference-sensitive on the backing array).
|
||||
- `SemanticVersionComparer` now enumerates the `IdentifierList` segments directly instead of
|
||||
re-splitting the joined identifier strings.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
219
src/semver.tests/IdentifierListTests.cs
Normal file
219
src/semver.tests/IdentifierListTests.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Geekeey.SemVer.Tests;
|
||||
|
||||
internal sealed class IdentifierListTests
|
||||
{
|
||||
[Test]
|
||||
public async Task I_can_represent_an_empty_list()
|
||||
{
|
||||
var empty = IdentifierList.Empty;
|
||||
|
||||
await Assert.That(empty.IsEmpty).IsTrue();
|
||||
await Assert.That(empty.Count).IsEqualTo(0);
|
||||
await Assert.That(empty.ToString()).IsEqualTo(string.Empty);
|
||||
|
||||
var collected = new List<string>();
|
||||
foreach (var segment in empty)
|
||||
{
|
||||
collected.Add(segment.ToString());
|
||||
}
|
||||
|
||||
await Assert.That(collected).IsEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_construct_from_an_empty_string()
|
||||
{
|
||||
var list = new IdentifierList(string.Empty);
|
||||
|
||||
await Assert.That(list.IsEmpty).IsTrue();
|
||||
await Assert.That(list.Count).IsEqualTo(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Arguments("alpha")]
|
||||
[Arguments("alpha.beta")]
|
||||
[Arguments("alpha.beta.1")]
|
||||
[Arguments("1.2.3.4.5")]
|
||||
public async Task I_can_count_dot_separated_segments(string joined)
|
||||
{
|
||||
var list = new IdentifierList(joined);
|
||||
var expected = joined.Split('.').Length;
|
||||
|
||||
await Assert.That(list.Count).IsEqualTo(expected);
|
||||
await Assert.That(list.IsEmpty).IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_get_each_segment_by_index()
|
||||
{
|
||||
var list = new IdentifierList("alpha.beta.1");
|
||||
|
||||
await Assert.That(list[0]).IsEqualTo("alpha");
|
||||
await Assert.That(list[1]).IsEqualTo("beta");
|
||||
await Assert.That(list[2]).IsEqualTo("1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_throw_out_of_range_for_an_invalid_index()
|
||||
{
|
||||
var list = new IdentifierList("alpha");
|
||||
|
||||
await Assert.That(() => list[1]).Throws<ArgumentOutOfRangeException>();
|
||||
await Assert.That(() => list[-1]).Throws<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_enumerate_segments_as_spans()
|
||||
{
|
||||
var list = new IdentifierList("alpha.beta.1");
|
||||
var collected = new List<string>();
|
||||
|
||||
foreach (var segment in list)
|
||||
{
|
||||
collected.Add(segment.ToString());
|
||||
}
|
||||
|
||||
await Assert.That(collected).IsEquivalentTo(["alpha", "beta", "1"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_enumerate_segments_as_strings()
|
||||
{
|
||||
var list = new IdentifierList("alpha.beta.1");
|
||||
var collected = new List<string>();
|
||||
|
||||
foreach (var segment in (IEnumerable<string>)list)
|
||||
{
|
||||
collected.Add(segment);
|
||||
}
|
||||
|
||||
await Assert.That(collected).IsEquivalentTo(["alpha", "beta", "1"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_compare_ordinally_with_other_lists_and_strings()
|
||||
{
|
||||
var list = new IdentifierList("alpha.beta");
|
||||
|
||||
await Assert.That(list.Equals(new IdentifierList("alpha.beta"))).IsTrue();
|
||||
await Assert.That(list == "alpha.beta").IsTrue();
|
||||
await Assert.That("alpha.beta" == list).IsTrue();
|
||||
await Assert.That(list.Equals("alpha.beta")).IsTrue();
|
||||
await Assert.That(list == new IdentifierList("alpha.BETA")).IsFalse();
|
||||
await Assert.That(list == "alpha.BETA").IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_report_inequality_with_a_null_string()
|
||||
{
|
||||
var list = new IdentifierList("alpha");
|
||||
|
||||
await Assert.That(list == (string?)null).IsFalse();
|
||||
await Assert.That(list.Equals((string?)null)).IsFalse();
|
||||
await Assert.That(IdentifierList.Empty == (string?)null).IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_report_equal_empty_lists()
|
||||
{
|
||||
await Assert.That(IdentifierList.Empty == new IdentifierList(string.Empty)).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_convert_implicitly_to_and_from_string()
|
||||
{
|
||||
IdentifierList list = "alpha.beta";
|
||||
string text = list;
|
||||
|
||||
await Assert.That(text).IsEqualTo("alpha.beta");
|
||||
await Assert.That(list == new IdentifierList("alpha.beta")).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_produce_consistent_hash_codes_for_equal_values()
|
||||
{
|
||||
await Assert.That(new IdentifierList("alpha").GetHashCode())
|
||||
.IsEqualTo(new IdentifierList("alpha").GetHashCode());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_round_trip_via_json_as_a_string()
|
||||
{
|
||||
var list = new IdentifierList("alpha.beta.1");
|
||||
|
||||
var json = JsonSerializer.Serialize(list);
|
||||
await Assert.That(json).IsEqualTo("\"alpha.beta.1\"");
|
||||
|
||||
var roundTripped = JsonSerializer.Deserialize<IdentifierList>(json);
|
||||
await Assert.That(roundTripped == list).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_deserialize_null_json_to_an_empty_list()
|
||||
{
|
||||
var roundTripped = JsonSerializer.Deserialize<IdentifierList>("null");
|
||||
|
||||
await Assert.That(roundTripped.IsEmpty).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Arguments("alpha")]
|
||||
[Arguments("alpha.beta")]
|
||||
[Arguments("alpha.beta.1")]
|
||||
[Arguments("0abc")]
|
||||
public async Task I_can_parse_valid_dot_separated_identifiers(string joined)
|
||||
{
|
||||
await Assert.That(IdentifierList.TryParse(joined, out var list)).IsTrue();
|
||||
await Assert.That(list.ToString()).IsEqualTo(joined);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Arguments("a..b")]
|
||||
[Arguments("a.")]
|
||||
[Arguments(".a")]
|
||||
[Arguments("a.b!")]
|
||||
[Arguments("a b")]
|
||||
public async Task I_can_reject_invalid_identifiers(string invalid)
|
||||
{
|
||||
await Assert.That(IdentifierList.TryParse(invalid, out _)).IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_forbid_leading_zero_numerics_via_format_info()
|
||||
{
|
||||
var prereleaseRule = new IdentifierListFormatInfo(forbidLeadingZeroInNumerics: true);
|
||||
|
||||
await Assert.That(IdentifierList.TryParse("01", prereleaseRule, out _)).IsFalse();
|
||||
await Assert.That(IdentifierList.TryParse("1", prereleaseRule, out _)).IsTrue();
|
||||
await Assert.That(IdentifierList.TryParse("0", prereleaseRule, out _)).IsTrue();
|
||||
await Assert.That(IdentifierList.TryParse("0abc", prereleaseRule, out _)).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_allow_leading_zero_numerics_for_metadata_rule()
|
||||
{
|
||||
var metadataRule = new IdentifierListFormatInfo(forbidLeadingZeroInNumerics: false);
|
||||
|
||||
await Assert.That(IdentifierList.TryParse("01", metadataRule, out _)).IsTrue();
|
||||
await Assert.That(IdentifierList.TryParse("0.1", metadataRule, out var list)).IsTrue();
|
||||
await Assert.That(list.ToString()).IsEqualTo("0.1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_throw_format_exception_for_invalid_parse()
|
||||
{
|
||||
await Assert.That(() => IdentifierList.Parse("a..b")).Throws<FormatException>();
|
||||
await Assert.That(() => IdentifierList.Parse("a.b!".AsSpan())).Throws<FormatException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_expose_default_format_info_allowing_leading_zeroes()
|
||||
{
|
||||
await Assert.That(IdentifierListFormatInfo.Default.ForbidLeadingZeroInNumerics).IsFalse();
|
||||
}
|
||||
}
|
||||
|
|
@ -26,8 +26,8 @@ internal sealed class SemanticVersionTests
|
|||
await Assert.That(v.Major).IsEqualTo((ulong)major);
|
||||
await Assert.That(v.Minor).IsEqualTo((ulong)minor);
|
||||
await Assert.That(v.Patch).IsEqualTo((ulong)build);
|
||||
await Assert.That(v.Prerelease).IsNull();
|
||||
await Assert.That(v.Metadata).IsNull();
|
||||
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -40,8 +40,8 @@ internal sealed class SemanticVersionTests
|
|||
await Assert.That(v.Major).IsEqualTo(major);
|
||||
await Assert.That(v.Minor).IsEqualTo(0UL);
|
||||
await Assert.That(v.Patch).IsEqualTo(0UL);
|
||||
await Assert.That(v.Prerelease).IsNull();
|
||||
await Assert.That(v.Metadata).IsNull();
|
||||
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -53,8 +53,8 @@ internal sealed class SemanticVersionTests
|
|||
await Assert.That(v.Major).IsEqualTo(major);
|
||||
await Assert.That(v.Minor).IsEqualTo(minor);
|
||||
await Assert.That(v.Patch).IsEqualTo(0UL);
|
||||
await Assert.That(v.Prerelease).IsNull();
|
||||
await Assert.That(v.Metadata).IsNull();
|
||||
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -66,8 +66,8 @@ internal sealed class SemanticVersionTests
|
|||
await Assert.That(v.Major).IsEqualTo(major);
|
||||
await Assert.That(v.Minor).IsEqualTo(minor);
|
||||
await Assert.That(v.Patch).IsEqualTo(patch);
|
||||
await Assert.That(v.Prerelease).IsNull();
|
||||
await Assert.That(v.Metadata).IsNull();
|
||||
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -80,8 +80,8 @@ internal sealed class SemanticVersionTests
|
|||
await Assert.That(v.Major).IsEqualTo(major);
|
||||
await Assert.That(v.Minor).IsEqualTo(minor);
|
||||
await Assert.That(v.Patch).IsEqualTo(patch);
|
||||
await Assert.That(v.Prerelease).IsEqualTo(prerelease);
|
||||
await Assert.That(v.Metadata).IsEqualTo(metadata);
|
||||
await Assert.That(v.Prerelease.ToString()).IsEqualTo(prerelease ?? string.Empty);
|
||||
await Assert.That(v.Metadata.ToString()).IsEqualTo(metadata ?? string.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -133,8 +133,8 @@ internal sealed class SemanticVersionTests
|
|||
await Assert.That(result.Major).IsEqualTo(1UL);
|
||||
await Assert.That(result.Minor).IsEqualTo(2UL);
|
||||
await Assert.That(result.Patch).IsEqualTo(3UL);
|
||||
await Assert.That(result.Prerelease).IsEqualTo("alpha");
|
||||
await Assert.That(result.Metadata).IsEqualTo("build.1");
|
||||
await Assert.That(result.Prerelease.ToString()).IsEqualTo("alpha");
|
||||
await Assert.That(result.Metadata.ToString()).IsEqualTo("build.1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -182,8 +182,8 @@ internal sealed class SemanticVersionTests
|
|||
await Assert.That(v.Major).IsEqualTo(1UL);
|
||||
await Assert.That(v.Minor).IsEqualTo(2UL);
|
||||
await Assert.That(v.Patch).IsEqualTo(3UL);
|
||||
await Assert.That(v.Prerelease).IsEqualTo("beta");
|
||||
await Assert.That(v.Metadata).IsEqualTo("789");
|
||||
await Assert.That(v.Prerelease.ToString()).IsEqualTo("beta");
|
||||
await Assert.That(v.Metadata.ToString()).IsEqualTo("789");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
516
src/semver/IdentifierList.cs
Normal file
516
src/semver/IdentifierList.cs
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Buffers;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Geekeey.SemVer;
|
||||
|
||||
/// <summary>
|
||||
/// A read-only, dot-separated list of semantic version identifiers (prerelease or build metadata).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The value behaves as an implicit <see cref="string"/> holding the original, joined identifier text (for example
|
||||
/// <c>"alpha.1"</c>). It additionally exposes the individual segments without allocating a string per segment: it
|
||||
/// implements <see cref="IReadOnlyList{T}"/> of <see cref="string"/> and yields <see cref="ReadOnlySpan{T}"/> of
|
||||
/// <see cref="char"/> when enumerated with <see langword="foreach"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Equality is always performed ordinally on the joined text. Semantic-version precedence (numeric versus alphanumeric
|
||||
/// prerelease ordering) is intentionally not part of this type and lives in <see cref="SemanticVersionComparer"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(IdentifierListJsonConverter))]
|
||||
public readonly struct IdentifierList : IReadOnlyList<string>, IEquatable<IdentifierList>, IEquatable<string?>, ISpanParsable<IdentifierList>
|
||||
{
|
||||
private readonly string? _value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets an <see cref="IdentifierList"/> that contains no identifiers.
|
||||
/// </summary>
|
||||
public static readonly IdentifierList Empty = default;
|
||||
|
||||
/// <summary>
|
||||
/// The set of characters that are legal within a single SemVer identifier.
|
||||
/// </summary>
|
||||
private static readonly SearchValues<char> ValidIdentifierChars =
|
||||
SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-");
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IdentifierList"/> struct from a joined identifier string.
|
||||
/// </summary>
|
||||
/// <param name="value">
|
||||
/// The dot-separated identifier text, or <see langword="null"/>/empty to create <see cref="Empty"/>.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The value is stored verbatim; splitting into segments happens lazily during enumeration or indexing. No
|
||||
/// identifier validation is performed here — that is the responsibility of <see cref="SemanticVersion"/> parsing.
|
||||
/// </remarks>
|
||||
public IdentifierList(string? value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this instance contains no identifiers.
|
||||
/// </summary>
|
||||
public bool IsEmpty => string.IsNullOrEmpty(_value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 1;
|
||||
for (var i = 0; i < _value!.Length; i++)
|
||||
{
|
||||
if (_value[i] is '.')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
if (index < 0 || index >= Count)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
|
||||
var start = 0;
|
||||
for (var i = 0; i < index; i++)
|
||||
{
|
||||
start = _value!.IndexOf('.', start) + 1;
|
||||
}
|
||||
|
||||
var end = _value!.IndexOf('.', start);
|
||||
if (end < 0)
|
||||
{
|
||||
end = _value.Length;
|
||||
}
|
||||
|
||||
return _value[start..end];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an enumerator that iterates over the identifiers as <see cref="ReadOnlySpan{T}"/> of <see cref="char"/>
|
||||
/// without allocating a string per segment.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="Enumerator"/> positioned before the first identifier.</returns>
|
||||
public Enumerator GetEnumerator()
|
||||
{
|
||||
return new Enumerator(_value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return _value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(IdentifierList other)
|
||||
{
|
||||
return string.Equals(_value ?? string.Empty, other._value ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(string? other)
|
||||
{
|
||||
return string.Equals(_value ?? string.Empty, other, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is IdentifierList list ? Equals(list) : obj is string s && Equals(s);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return (_value ?? string.Empty).GetHashCode();
|
||||
}
|
||||
|
||||
/// <summary>Determines whether two <see cref="IdentifierList"/> values are ordinally equal.</summary>
|
||||
public static bool operator ==(IdentifierList left, IdentifierList right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>Determines whether two <see cref="IdentifierList"/> values differ.</summary>
|
||||
public static bool operator !=(IdentifierList left, IdentifierList right)
|
||||
{
|
||||
return !left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>Determines whether an <see cref="IdentifierList"/> and a <see cref="string"/> are ordinally equal.</summary>
|
||||
public static bool operator ==(IdentifierList left, string? right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>Determines whether an <see cref="IdentifierList"/> and a <see cref="string"/> differ.</summary>
|
||||
public static bool operator !=(IdentifierList left, string? right)
|
||||
{
|
||||
return !left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>Determines whether a <see cref="string"/> and an <see cref="IdentifierList"/> are ordinally equal.</summary>
|
||||
public static bool operator ==(string? left, IdentifierList right)
|
||||
{
|
||||
return right.Equals(left);
|
||||
}
|
||||
|
||||
/// <summary>Determines whether a <see cref="string"/> and an <see cref="IdentifierList"/> differ.</summary>
|
||||
public static bool operator !=(string? left, IdentifierList right)
|
||||
{
|
||||
return !right.Equals(left);
|
||||
}
|
||||
|
||||
/// <summary>Defines an implicit conversion from <see cref="IdentifierList"/> to its joined <see cref="string"/> form.</summary>
|
||||
public static implicit operator string(IdentifierList value)
|
||||
{
|
||||
return value._value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Defines an implicit conversion from a joined <see cref="string"/> to an <see cref="IdentifierList"/>.</summary>
|
||||
public static implicit operator IdentifierList(string? value)
|
||||
{
|
||||
return new IdentifierList(value);
|
||||
}
|
||||
|
||||
#region IParsable
|
||||
|
||||
/// <summary>Parses a string into an <see cref="IdentifierList"/> using the default identifier rules.</summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <returns>The parsed <see cref="IdentifierList"/>.</returns>
|
||||
/// <exception cref="FormatException"><paramref name="s"/> is not a valid dot-separated identifier list.</exception>
|
||||
public static IdentifierList Parse(string? s)
|
||||
{
|
||||
return Parse(s.AsSpan(), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a string into an <see cref="IdentifierList"/>, validating it against the SemVer identifier rules.
|
||||
/// </summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <param name="provider">See <see cref="Parse(ReadOnlySpan{char}, IFormatProvider?)"/>.</param>
|
||||
/// <returns>The parsed <see cref="IdentifierList"/>.</returns>
|
||||
/// <exception cref="FormatException"><paramref name="s"/> is not a valid dot-separated identifier list.</exception>
|
||||
public static IdentifierList Parse(string? s, IFormatProvider? provider)
|
||||
{
|
||||
return Parse(s.AsSpan(), provider);
|
||||
}
|
||||
|
||||
/// <summary>Tries to parse a string into an <see cref="IdentifierList"/> using the default identifier rules.</summary>
|
||||
public static bool TryParse(string? s, out IdentifierList result)
|
||||
{
|
||||
return TryParse(s.AsSpan(), null, out result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a string into an <see cref="IdentifierList"/>.
|
||||
/// </summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <param name="provider">See <see cref="Parse(ReadOnlySpan{char}, IFormatProvider?)"/>.</param>
|
||||
/// <param name="result">The parsed list, or <see cref="Empty"/> on failure.</param>
|
||||
/// <returns><see langword="true"/> if <paramref name="s"/> was parsed successfully; otherwise <see langword="false"/>.</returns>
|
||||
public static bool TryParse(string? s, IFormatProvider? provider, out IdentifierList result)
|
||||
{
|
||||
return TryParse(s.AsSpan(), provider, out result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>Parses a span of characters into an <see cref="IdentifierList"/> using the default identifier rules.</summary>
|
||||
/// <param name="s">The span of characters to parse.</param>
|
||||
/// <returns>The parsed <see cref="IdentifierList"/>.</returns>
|
||||
/// <exception cref="FormatException"><paramref name="s"/> is not a valid dot-separated identifier list.</exception>
|
||||
public static IdentifierList Parse(ReadOnlySpan<char> s)
|
||||
{
|
||||
return Parse(s, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a span of characters into an <see cref="IdentifierList"/>, validating it against the SemVer identifier rules.
|
||||
/// </summary>
|
||||
/// <param name="s">The span of characters to parse.</param>
|
||||
/// <param name="provider">
|
||||
/// An <see cref="IFormatProvider"/> that supplies an <see cref="IdentifierListFormatInfo"/>; its
|
||||
/// <see cref="IdentifierListFormatInfo.ForbidLeadingZeroInNumerics"/> controls whether all-numeric identifiers may carry
|
||||
/// leading zeroes. When omitted, leading zeroes are allowed (build-metadata rules).
|
||||
/// </param>
|
||||
/// <returns>The parsed <see cref="IdentifierList"/>.</returns>
|
||||
/// <exception cref="FormatException"><paramref name="s"/> is not a valid dot-separated identifier list.</exception>
|
||||
public static IdentifierList Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
|
||||
{
|
||||
if (!TryParse(s, provider, out var result))
|
||||
{
|
||||
throw new FormatException($"The identifier list '{s}' was not in a correct format.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Tries to parse a span of characters into an <see cref="IdentifierList"/> using the default identifier rules.</summary>
|
||||
public static bool TryParse(ReadOnlySpan<char> s, out IdentifierList result)
|
||||
{
|
||||
return TryParse(s, null, out result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a span of characters into an <see cref="IdentifierList"/>.
|
||||
/// </summary>
|
||||
/// <param name="s">The span of characters to parse.</param>
|
||||
/// <param name="provider">See <see cref="Parse(ReadOnlySpan{char}, IFormatProvider?)"/>.</param>
|
||||
/// <param name="result">The parsed list, or <see cref="Empty"/> on failure.</param>
|
||||
/// <returns><see langword="true"/> if <paramref name="s"/> was parsed successfully; otherwise <see langword="false"/>.</returns>
|
||||
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, out IdentifierList result)
|
||||
{
|
||||
var formatInfo = IdentifierListFormatInfo.GetInstance(provider);
|
||||
|
||||
if (!IsValidIdentifiers(s, formatInfo.ForbidLeadingZeroInNumerics))
|
||||
{
|
||||
result = Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new IdentifierList(new string(s));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator<string> IEnumerable<string>.GetEnumerator()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_value))
|
||||
{
|
||||
return ((IEnumerable<string>)[]).GetEnumerator();
|
||||
}
|
||||
|
||||
var enumerator = GetEnumerator();
|
||||
var segments = new List<string>(Count);
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
segments.Add(new string(enumerator.Current));
|
||||
}
|
||||
|
||||
return segments.GetEnumerator();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return ((IEnumerable<string>)this).GetEnumerator();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the given dot-separated identifier text conforms to the SemVer 2.0.0 identifier rules.
|
||||
/// </summary>
|
||||
/// <param name="value">The joined identifier text to validate.</param>
|
||||
/// <param name="forbidLeadingZeroInNumerics">
|
||||
/// <see langword="true"/> to reject numeric identifiers with leading zeroes (used for prerelease identifiers);
|
||||
/// <see langword="false"/> to allow them (used for build metadata identifiers).
|
||||
/// </param>
|
||||
/// <returns><see langword="true"/> if every dot-separated segment is a valid SemVer identifier; otherwise <see langword="false"/>.</returns>
|
||||
private static bool IsValidIdentifiers(ReadOnlySpan<char> value, bool forbidLeadingZeroInNumerics)
|
||||
{
|
||||
if (value.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation is per-identifier (dot-separated), not per-string: an empty
|
||||
// segment ("a..b", "a.") is invalid, and the leading-zero ban applies only
|
||||
// to numeric identifiers (so "0abc" stays valid while "02" does not).
|
||||
var start = 0;
|
||||
for (var i = 0; i <= value.Length; i++)
|
||||
{
|
||||
if (i != value.Length && value[i] is not '.')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = value[start..i];
|
||||
|
||||
// Reject empty segments produced by adjacent or trailing dots.
|
||||
if (identifier.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Every character must be an allowed identifier character.
|
||||
if (identifier.IndexOfAnyExcept(ValidIdentifierChars) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric identifiers must not include leading zeroes (SemVer 2.0.0 §9/§10).
|
||||
// The caller ensures the rule fires only for all-digit identifiers,
|
||||
// leaving an alphanumeric "0abc" (which is allowed) untouched.
|
||||
if (forbidLeadingZeroInNumerics && identifier.Length > 1 && identifier[0] is '0' &&
|
||||
ulong.TryParse(identifier, NumberStyles.None, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
start = i + 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the identifiers of an <see cref="IdentifierList"/> as <see cref="ReadOnlySpan{T}"/> of
|
||||
/// <see cref="char"/>, avoiding per-segment string allocations.
|
||||
/// </summary>
|
||||
public ref struct Enumerator
|
||||
{
|
||||
private readonly string? _value;
|
||||
private int _next;
|
||||
|
||||
internal Enumerator(string? value)
|
||||
{
|
||||
_value = value;
|
||||
_next = 0;
|
||||
Current = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identifier at the current position as a <see cref="ReadOnlySpan{T}"/> of <see cref="char"/>.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<char> Current { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advances the enumerator to the next identifier.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> if an identifier is available; otherwise <see langword="false"/>.</returns>
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_next > _value.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dot = _value.IndexOf('.', _next);
|
||||
if (dot < 0)
|
||||
{
|
||||
Current = _value.AsSpan(_next);
|
||||
_next = _value.Length + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
Current = _value.AsSpan(_next, dot - _next);
|
||||
_next = dot + 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the SemVer identifier rules used when parsing an <see cref="IdentifierList"/>.
|
||||
/// </summary>
|
||||
public sealed class IdentifierListFormatInfo : IFormatProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an <see cref="IdentifierListFormatInfo"/> that allows leading zeroes in all-numeric identifiers
|
||||
/// (the build-metadata rule).
|
||||
/// </summary>
|
||||
public static readonly IdentifierListFormatInfo Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IdentifierListFormatInfo"/> class.
|
||||
/// </summary>
|
||||
/// <param name="forbidLeadingZeroInNumerics">
|
||||
/// <see langword="true"/> to reject all-numeric identifiers with leading zeroes (the prerelease rule);
|
||||
/// <see langword="false"/> to allow them (the build-metadata rule).
|
||||
/// </param>
|
||||
public IdentifierListFormatInfo(bool forbidLeadingZeroInNumerics = false)
|
||||
{
|
||||
ForbidLeadingZeroInNumerics = forbidLeadingZeroInNumerics;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether all-numeric identifiers are forbidden from carrying leading zeroes.
|
||||
/// </summary>
|
||||
public bool ForbidLeadingZeroInNumerics { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? GetFormat(Type? formatType)
|
||||
{
|
||||
return formatType == typeof(IdentifierListFormatInfo) ? this : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="IdentifierListFormatInfo"/> associated with the specified <see cref="IFormatProvider"/>.
|
||||
/// </summary>
|
||||
/// <param name="provider">The format provider, or <see langword="null"/> for <see cref="Default"/>.</param>
|
||||
/// <returns>
|
||||
/// <paramref name="provider"/> when it is an <see cref="IdentifierListFormatInfo"/>, the instance it returns from
|
||||
/// <see cref="GetFormat"/>, or <see cref="Default"/> otherwise.
|
||||
/// </returns>
|
||||
public static IdentifierListFormatInfo GetInstance(IFormatProvider? provider)
|
||||
{
|
||||
if (provider is IdentifierListFormatInfo info)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
|
||||
if (provider?.GetFormat(typeof(IdentifierListFormatInfo)) is IdentifierListFormatInfo fromProvider)
|
||||
{
|
||||
return fromProvider;
|
||||
}
|
||||
|
||||
return Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an <see cref="IdentifierList"/> to and from its joined <see cref="string"/> JSON representation.
|
||||
/// </summary>
|
||||
internal sealed class IdentifierListJsonConverter : JsonConverter<IdentifierList>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override IdentifierList Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType is JsonTokenType.Null)
|
||||
{
|
||||
return IdentifierList.Empty;
|
||||
}
|
||||
|
||||
if (reader.TokenType is not JsonTokenType.String || reader.GetString() is not { } value)
|
||||
{
|
||||
throw new JsonException("Expected string");
|
||||
}
|
||||
|
||||
return new IdentifierList(value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, IdentifierList value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.ToString());
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ public readonly partial struct SemanticVersion : ISpanFormattable
|
|||
destination = destination[written..];
|
||||
charsWritten += written;
|
||||
|
||||
if (Prerelease is { Length: > 0 } && format is "s" or "f")
|
||||
if (!Prerelease.IsEmpty && format is "s" or "f")
|
||||
{
|
||||
if (!destination.TryWrite(provider, $"-{Prerelease}", out written))
|
||||
{
|
||||
|
|
@ -91,7 +91,7 @@ public readonly partial struct SemanticVersion : ISpanFormattable
|
|||
charsWritten += written;
|
||||
}
|
||||
|
||||
if (Metadata is { Length: > 0 } && format is "f")
|
||||
if (!Metadata.IsEmpty && format is "f")
|
||||
{
|
||||
if (!destination.TryWrite(provider, $"+{Metadata}", out written))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
|||
/// <see cref="TryParse(string, IFormatProvider, out SemanticVersion)"/>
|
||||
public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersion result)
|
||||
{
|
||||
return TryParse(s, null, out result);
|
||||
return TryParse(s.AsSpan(), null, out result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -85,9 +85,6 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
|||
// components = 1: wildcard at minor
|
||||
// components = 2: wildcard at patch
|
||||
// components = 3: no wildcards
|
||||
private static readonly System.Buffers.SearchValues<char> ValidIdentifierChars =
|
||||
System.Buffers.SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-");
|
||||
|
||||
internal static bool TryParsePartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components)
|
||||
{
|
||||
version = default;
|
||||
|
|
@ -98,7 +95,7 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
|||
return false;
|
||||
}
|
||||
|
||||
var metadata = default(string);
|
||||
var metadata = IdentifierList.Empty;
|
||||
if (s.IndexOf('+') is >= 0 and var metadataIndex)
|
||||
{
|
||||
if (s[(metadataIndex + 1)..] is not { IsEmpty: false } value)
|
||||
|
|
@ -106,11 +103,15 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
|||
return false;
|
||||
}
|
||||
|
||||
metadata = new string(value);
|
||||
if (!IdentifierList.TryParse(value, new IdentifierListFormatInfo(forbidLeadingZeroInNumerics: false), out metadata))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
s = s[..metadataIndex];
|
||||
}
|
||||
|
||||
var prerelease = default(string);
|
||||
var prerelease = IdentifierList.Empty;
|
||||
if (s.IndexOf('-') is >= 0 and var prereleaseIndex)
|
||||
{
|
||||
if (s[(prereleaseIndex + 1)..] is not { IsEmpty: false } value)
|
||||
|
|
@ -118,7 +119,11 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
|||
return false;
|
||||
}
|
||||
|
||||
prerelease = new string(value);
|
||||
if (!IdentifierList.TryParse(value, new IdentifierListFormatInfo(forbidLeadingZeroInNumerics: true), out prerelease))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
s = s[..prereleaseIndex];
|
||||
}
|
||||
|
||||
|
|
@ -158,67 +163,7 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
|||
component[components++] = value;
|
||||
}
|
||||
|
||||
// Per SemVer 2.0.0 the core version numbers and numeric prerelease identifiers
|
||||
// MUST NOT include leading zeroes, and prerelease/metadata identifiers MUST be
|
||||
// composed only of ASCII alphanumerics and hyphens.
|
||||
if (metadata is not null && !IsValidIdentifiers(metadata, forbidLeadingZeroInNumerics: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prerelease is not null && !IsValidIdentifiers(prerelease, forbidLeadingZeroInNumerics: true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsValidIdentifiers(ReadOnlySpan<char> value, bool forbidLeadingZeroInNumerics)
|
||||
{
|
||||
if (value.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation is per-identifier (dot-separated), not per-string: an empty
|
||||
// segment ("a..b", "a.") is invalid, and the leading-zero ban applies only
|
||||
// to numeric identifiers (so "0abc" stays valid while "02" does not).
|
||||
var start = 0;
|
||||
for (var i = 0; i <= value.Length; i++)
|
||||
{
|
||||
if (i != value.Length && value[i] is not '.')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = value[start..i];
|
||||
|
||||
// Reject empty segments produced by adjacent or trailing dots.
|
||||
if (identifier.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Every character must be an allowed identifier character.
|
||||
if (identifier.IndexOfAnyExcept(ValidIdentifierChars) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric identifiers must not include leading zeroes (SemVer 2.0.0 §9/§10).
|
||||
// The TryParse gate ensures the rule fires only for all-digit identifiers,
|
||||
// leaving an alphanumeric "0abc" (which is allowed) untouched.
|
||||
if (forbidLeadingZeroInNumerics && identifier.Length > 1 && identifier[0] is '0' &&
|
||||
ulong.TryParse(identifier, NumberStyles.None, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
start = i + 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public readonly partial struct SemanticVersion : IEquatable<SemanticVersion>
|
|||
/// </summary>
|
||||
/// <param name="major">The major version number.</param>
|
||||
public SemanticVersion(ulong major)
|
||||
: this(major, 0, 0, default, default)
|
||||
: this(major, 0, 0, IdentifierList.Empty, IdentifierList.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ public readonly partial struct SemanticVersion : IEquatable<SemanticVersion>
|
|||
/// <param name="major">The major version number.</param>
|
||||
/// <param name="minor">The minor version number.</param>
|
||||
public SemanticVersion(ulong major, ulong minor)
|
||||
: this(major, minor, 0, default, default)
|
||||
: this(major, minor, 0, IdentifierList.Empty, IdentifierList.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ public readonly partial struct SemanticVersion : IEquatable<SemanticVersion>
|
|||
/// <param name="minor">The minor version number.</param>
|
||||
/// <param name="patch">The patch version number.</param>
|
||||
public SemanticVersion(ulong major, ulong minor, ulong patch)
|
||||
: this(major, minor, patch, default, default)
|
||||
: this(major, minor, patch, IdentifierList.Empty, IdentifierList.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +61,19 @@ public readonly partial struct SemanticVersion : IEquatable<SemanticVersion>
|
|||
/// <param name="prerelease">The prerelease identifiers.</param>
|
||||
/// <param name="metadata">The build metadata identifiers.</param>
|
||||
public SemanticVersion(ulong major, ulong minor, ulong patch, string? prerelease, string? metadata)
|
||||
: this(major, minor, patch, new IdentifierList(prerelease), new IdentifierList(metadata))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new instance of the <see cref="SemanticVersion" /> class.
|
||||
/// </summary>
|
||||
/// <param name="major">The major version number.</param>
|
||||
/// <param name="minor">The minor version number.</param>
|
||||
/// <param name="patch">The patch version number.</param>
|
||||
/// <param name="prerelease">The prerelease identifiers.</param>
|
||||
/// <param name="metadata">The build metadata identifiers.</param>
|
||||
public SemanticVersion(ulong major, ulong minor, ulong patch, IdentifierList prerelease, IdentifierList metadata)
|
||||
{
|
||||
Major = major;
|
||||
Minor = minor;
|
||||
|
|
@ -91,20 +104,20 @@ public readonly partial struct SemanticVersion : IEquatable<SemanticVersion>
|
|||
public ulong Patch { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prerelease label of the semantic version. This value is an optional string
|
||||
/// indicating a version that is still in development or not yet considered stable.
|
||||
/// Examples of prerelease labels include "alpha", "beta", or "rc".
|
||||
/// When present, the prerelease label is used to differentiate between versions with
|
||||
/// Gets the prerelease identifiers of the semantic version. This value is an
|
||||
/// <see cref="IdentifierList"/> indicating a version that is still in development or not yet considered stable.
|
||||
/// Examples of prerelease identifiers include "alpha", "beta", or "rc".
|
||||
/// When present, the prerelease identifiers are used to differentiate between versions with
|
||||
/// the same major, minor, and patch numbers.
|
||||
/// </summary>
|
||||
public string? Prerelease { get; }
|
||||
public IdentifierList Prerelease { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the build metadata associated with the semantic version. This optional value provides
|
||||
/// additional information about the build, such as build number, commit hash, or other relevant
|
||||
/// details. It does not affect the precedence of the version.
|
||||
/// </summary>
|
||||
public string? Metadata { get; }
|
||||
public IdentifierList Metadata { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(SemanticVersion other)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Geekeey.SemVer;
|
||||
|
||||
|
|
@ -111,7 +112,7 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
|||
return v1.Major == v2.Major &&
|
||||
v1.Minor == v2.Minor &&
|
||||
v1.Patch == v2.Patch &&
|
||||
string.Equals(v1.Prerelease, v2.Prerelease, StringComparison.Ordinal);
|
||||
v1.Prerelease.Equals(v2.Prerelease);
|
||||
}
|
||||
|
||||
public override int GetHashCode(SemanticVersion? v)
|
||||
|
|
@ -192,8 +193,8 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
|||
return v1.Major == v2.Major &&
|
||||
v1.Minor == v2.Minor &&
|
||||
v1.Patch == v2.Patch &&
|
||||
string.Equals(v1.Prerelease, v2.Prerelease, StringComparison.Ordinal) &&
|
||||
string.Equals(v1.Metadata, v2.Metadata, StringComparison.Ordinal);
|
||||
v1.Prerelease.Equals(v2.Prerelease) &&
|
||||
v1.Metadata.Equals(v2.Metadata);
|
||||
}
|
||||
|
||||
public override int GetHashCode(SemanticVersion? v)
|
||||
|
|
@ -210,38 +211,55 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
|||
public override int Compare(SemanticVersion? x, SemanticVersion? y)
|
||||
{
|
||||
var cmp = Priority.Compare(x, y);
|
||||
return cmp is not 0 ? cmp : CompareMetadata(x?.Metadata, y?.Metadata);
|
||||
return cmp is not 0 ? cmp : CompareMetadata(x?.Metadata ?? IdentifierList.Empty, y?.Metadata ?? IdentifierList.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ComparePrerelease(string? x, string? y)
|
||||
private static int ComparePrerelease(IdentifierList x, IdentifierList y)
|
||||
{
|
||||
if (string.Equals(x, y, StringComparison.Ordinal))
|
||||
if (x.Equals(y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
if (x.IsEmpty)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
if (y.IsEmpty)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var xParts = x.Split('.');
|
||||
var yParts = y.Split('.');
|
||||
var xEnumerator = x.GetEnumerator();
|
||||
var yEnumerator = y.GetEnumerator();
|
||||
|
||||
var length = Math.Min(xParts.Length, yParts.Length);
|
||||
for (var i = 0; i < length; i++)
|
||||
while (true)
|
||||
{
|
||||
var xPart = xParts[i];
|
||||
var yPart = yParts[i];
|
||||
var xHas = xEnumerator.MoveNext();
|
||||
var yHas = yEnumerator.MoveNext();
|
||||
|
||||
var xIsNumeric = ulong.TryParse(xPart, out var xNum);
|
||||
var yIsNumeric = ulong.TryParse(yPart, out var yNum);
|
||||
if (!xHas && !yHas)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!xHas)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!yHas)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var xPart = xEnumerator.Current;
|
||||
var yPart = yEnumerator.Current;
|
||||
|
||||
var xIsNumeric = ulong.TryParse(xPart, NumberStyles.None, CultureInfo.InvariantCulture, out var xNum);
|
||||
var yIsNumeric = ulong.TryParse(yPart, NumberStyles.None, CultureInfo.InvariantCulture, out var yNum);
|
||||
|
||||
if (xIsNumeric && yIsNumeric)
|
||||
{
|
||||
|
|
@ -261,47 +279,60 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
|||
}
|
||||
else
|
||||
{
|
||||
var cmp = string.CompareOrdinal(xPart, yPart);
|
||||
var cmp = xPart.SequenceCompareTo(yPart);
|
||||
if (cmp is not 0)
|
||||
{
|
||||
return cmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return xParts.Length.CompareTo(yParts.Length);
|
||||
}
|
||||
|
||||
private static int CompareMetadata(string? x, string? y)
|
||||
private static int CompareMetadata(IdentifierList x, IdentifierList y)
|
||||
{
|
||||
if (string.Equals(x, y, StringComparison.Ordinal))
|
||||
if (x.Equals(y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
if (x.IsEmpty)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
if (y.IsEmpty)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var xParts = x.Split('.');
|
||||
var yParts = y.Split('.');
|
||||
var xEnumerator = x.GetEnumerator();
|
||||
var yEnumerator = y.GetEnumerator();
|
||||
|
||||
var length = Math.Min(xParts.Length, yParts.Length);
|
||||
for (var i = 0; i < length; i++)
|
||||
while (true)
|
||||
{
|
||||
var cmp = string.CompareOrdinal(xParts[i], yParts[i]);
|
||||
var xHas = xEnumerator.MoveNext();
|
||||
var yHas = yEnumerator.MoveNext();
|
||||
|
||||
if (!xHas && !yHas)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!xHas)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!yHas)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var cmp = xEnumerator.Current.SequenceCompareTo(yEnumerator.Current);
|
||||
if (cmp is not 0)
|
||||
{
|
||||
return cmp;
|
||||
}
|
||||
}
|
||||
|
||||
return xParts.Length.CompareTo(yParts.Length);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ public readonly partial struct SemanticVersionRange : ISpanFormattable
|
|||
return false;
|
||||
}
|
||||
|
||||
if (upper.Operation is Comparison.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } })
|
||||
if (upper.Operation is Comparison.Lt && hi is { Minor: 0, Patch: 0, Prerelease.IsEmpty: true })
|
||||
{
|
||||
if (lo is { Major: > 0 } && hi.Major == lo.Major + 1)
|
||||
{
|
||||
|
|
@ -168,7 +168,7 @@ public readonly partial struct SemanticVersionRange : ISpanFormattable
|
|||
}
|
||||
}
|
||||
|
||||
if (upper.Operation is Comparison.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } })
|
||||
if (upper.Operation is Comparison.Lt && hi is { Patch: 0, Prerelease.IsEmpty: true })
|
||||
{
|
||||
if (hi.Major == lo.Major && hi.Minor == lo.Minor + 1)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue