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`
|
- `SemanticVersion` and `SemanticVersionRange` can now be used as `System.Text.Json`
|
||||||
dictionary keys. The custom converters override `ReadAsPropertyName`/`WriteAsPropertyName`
|
dictionary keys. The custom converters override `ReadAsPropertyName`/`WriteAsPropertyName`
|
||||||
so `Dictionary<TKey, TValue>` round-trips correctly.
|
so `Dictionary<TKey, TValue>` round-trips correctly.
|
||||||
|
- New `IdentifierList` value type for prerelease/build-metadata identifiers.
|
||||||
|
|
||||||
### Changed
|
### 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.
|
- `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules.
|
||||||
- `SemanticVersionRange` equality (`==`, `Equals`, `GetHashCode`) is now structural and based
|
- `SemanticVersionRange` equality (`==`, `Equals`, `GetHashCode`) is now structural and based
|
||||||
on the constraint sets, so ranges that parse to the same comparators compare as equal
|
on the constraint sets, so ranges that parse to the same comparators compare as equal
|
||||||
(previously it was reference-sensitive on the backing array).
|
(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
|
### 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.Major).IsEqualTo((ulong)major);
|
||||||
await Assert.That(v.Minor).IsEqualTo((ulong)minor);
|
await Assert.That(v.Minor).IsEqualTo((ulong)minor);
|
||||||
await Assert.That(v.Patch).IsEqualTo((ulong)build);
|
await Assert.That(v.Patch).IsEqualTo((ulong)build);
|
||||||
await Assert.That(v.Prerelease).IsNull();
|
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||||
await Assert.That(v.Metadata).IsNull();
|
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -40,8 +40,8 @@ internal sealed class SemanticVersionTests
|
||||||
await Assert.That(v.Major).IsEqualTo(major);
|
await Assert.That(v.Major).IsEqualTo(major);
|
||||||
await Assert.That(v.Minor).IsEqualTo(0UL);
|
await Assert.That(v.Minor).IsEqualTo(0UL);
|
||||||
await Assert.That(v.Patch).IsEqualTo(0UL);
|
await Assert.That(v.Patch).IsEqualTo(0UL);
|
||||||
await Assert.That(v.Prerelease).IsNull();
|
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||||
await Assert.That(v.Metadata).IsNull();
|
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -53,8 +53,8 @@ internal sealed class SemanticVersionTests
|
||||||
await Assert.That(v.Major).IsEqualTo(major);
|
await Assert.That(v.Major).IsEqualTo(major);
|
||||||
await Assert.That(v.Minor).IsEqualTo(minor);
|
await Assert.That(v.Minor).IsEqualTo(minor);
|
||||||
await Assert.That(v.Patch).IsEqualTo(0UL);
|
await Assert.That(v.Patch).IsEqualTo(0UL);
|
||||||
await Assert.That(v.Prerelease).IsNull();
|
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||||
await Assert.That(v.Metadata).IsNull();
|
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -66,8 +66,8 @@ internal sealed class SemanticVersionTests
|
||||||
await Assert.That(v.Major).IsEqualTo(major);
|
await Assert.That(v.Major).IsEqualTo(major);
|
||||||
await Assert.That(v.Minor).IsEqualTo(minor);
|
await Assert.That(v.Minor).IsEqualTo(minor);
|
||||||
await Assert.That(v.Patch).IsEqualTo(patch);
|
await Assert.That(v.Patch).IsEqualTo(patch);
|
||||||
await Assert.That(v.Prerelease).IsNull();
|
await Assert.That(v.Prerelease.IsEmpty).IsTrue();
|
||||||
await Assert.That(v.Metadata).IsNull();
|
await Assert.That(v.Metadata.IsEmpty).IsTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -80,8 +80,8 @@ internal sealed class SemanticVersionTests
|
||||||
await Assert.That(v.Major).IsEqualTo(major);
|
await Assert.That(v.Major).IsEqualTo(major);
|
||||||
await Assert.That(v.Minor).IsEqualTo(minor);
|
await Assert.That(v.Minor).IsEqualTo(minor);
|
||||||
await Assert.That(v.Patch).IsEqualTo(patch);
|
await Assert.That(v.Patch).IsEqualTo(patch);
|
||||||
await Assert.That(v.Prerelease).IsEqualTo(prerelease);
|
await Assert.That(v.Prerelease.ToString()).IsEqualTo(prerelease ?? string.Empty);
|
||||||
await Assert.That(v.Metadata).IsEqualTo(metadata);
|
await Assert.That(v.Metadata.ToString()).IsEqualTo(metadata ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -133,8 +133,8 @@ internal sealed class SemanticVersionTests
|
||||||
await Assert.That(result.Major).IsEqualTo(1UL);
|
await Assert.That(result.Major).IsEqualTo(1UL);
|
||||||
await Assert.That(result.Minor).IsEqualTo(2UL);
|
await Assert.That(result.Minor).IsEqualTo(2UL);
|
||||||
await Assert.That(result.Patch).IsEqualTo(3UL);
|
await Assert.That(result.Patch).IsEqualTo(3UL);
|
||||||
await Assert.That(result.Prerelease).IsEqualTo("alpha");
|
await Assert.That(result.Prerelease.ToString()).IsEqualTo("alpha");
|
||||||
await Assert.That(result.Metadata).IsEqualTo("build.1");
|
await Assert.That(result.Metadata.ToString()).IsEqualTo("build.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|
@ -182,8 +182,8 @@ internal sealed class SemanticVersionTests
|
||||||
await Assert.That(v.Major).IsEqualTo(1UL);
|
await Assert.That(v.Major).IsEqualTo(1UL);
|
||||||
await Assert.That(v.Minor).IsEqualTo(2UL);
|
await Assert.That(v.Minor).IsEqualTo(2UL);
|
||||||
await Assert.That(v.Patch).IsEqualTo(3UL);
|
await Assert.That(v.Patch).IsEqualTo(3UL);
|
||||||
await Assert.That(v.Prerelease).IsEqualTo("beta");
|
await Assert.That(v.Prerelease.ToString()).IsEqualTo("beta");
|
||||||
await Assert.That(v.Metadata).IsEqualTo("789");
|
await Assert.That(v.Metadata.ToString()).IsEqualTo("789");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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..];
|
destination = destination[written..];
|
||||||
charsWritten += 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))
|
if (!destination.TryWrite(provider, $"-{Prerelease}", out written))
|
||||||
{
|
{
|
||||||
|
|
@ -91,7 +91,7 @@ public readonly partial struct SemanticVersion : ISpanFormattable
|
||||||
charsWritten += written;
|
charsWritten += written;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Metadata is { Length: > 0 } && format is "f")
|
if (!Metadata.IsEmpty && format is "f")
|
||||||
{
|
{
|
||||||
if (!destination.TryWrite(provider, $"+{Metadata}", out written))
|
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)"/>
|
/// <see cref="TryParse(string, IFormatProvider, out SemanticVersion)"/>
|
||||||
public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersion result)
|
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 />
|
/// <inheritdoc />
|
||||||
|
|
@ -85,9 +85,6 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
||||||
// components = 1: wildcard at minor
|
// components = 1: wildcard at minor
|
||||||
// components = 2: wildcard at patch
|
// components = 2: wildcard at patch
|
||||||
// components = 3: no wildcards
|
// 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)
|
internal static bool TryParsePartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components)
|
||||||
{
|
{
|
||||||
version = default;
|
version = default;
|
||||||
|
|
@ -98,7 +95,7 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata = default(string);
|
var metadata = IdentifierList.Empty;
|
||||||
if (s.IndexOf('+') is >= 0 and var metadataIndex)
|
if (s.IndexOf('+') is >= 0 and var metadataIndex)
|
||||||
{
|
{
|
||||||
if (s[(metadataIndex + 1)..] is not { IsEmpty: false } value)
|
if (s[(metadataIndex + 1)..] is not { IsEmpty: false } value)
|
||||||
|
|
@ -106,11 +103,15 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata = new string(value);
|
if (!IdentifierList.TryParse(value, new IdentifierListFormatInfo(forbidLeadingZeroInNumerics: false), out metadata))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
s = s[..metadataIndex];
|
s = s[..metadataIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
var prerelease = default(string);
|
var prerelease = IdentifierList.Empty;
|
||||||
if (s.IndexOf('-') is >= 0 and var prereleaseIndex)
|
if (s.IndexOf('-') is >= 0 and var prereleaseIndex)
|
||||||
{
|
{
|
||||||
if (s[(prereleaseIndex + 1)..] is not { IsEmpty: false } value)
|
if (s[(prereleaseIndex + 1)..] is not { IsEmpty: false } value)
|
||||||
|
|
@ -118,7 +119,11 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
prerelease = new string(value);
|
if (!IdentifierList.TryParse(value, new IdentifierListFormatInfo(forbidLeadingZeroInNumerics: true), out prerelease))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
s = s[..prereleaseIndex];
|
s = s[..prereleaseIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,67 +163,7 @@ public readonly partial struct SemanticVersion : ISpanParsable<SemanticVersion>
|
||||||
component[components++] = value;
|
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);
|
version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata);
|
||||||
return true;
|
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>
|
/// </summary>
|
||||||
/// <param name="major">The major version number.</param>
|
/// <param name="major">The major version number.</param>
|
||||||
public SemanticVersion(ulong major)
|
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="major">The major version number.</param>
|
||||||
/// <param name="minor">The minor version number.</param>
|
/// <param name="minor">The minor version number.</param>
|
||||||
public SemanticVersion(ulong major, ulong minor)
|
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="minor">The minor version number.</param>
|
||||||
/// <param name="patch">The patch version number.</param>
|
/// <param name="patch">The patch version number.</param>
|
||||||
public SemanticVersion(ulong major, ulong minor, ulong patch)
|
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="prerelease">The prerelease identifiers.</param>
|
||||||
/// <param name="metadata">The build metadata identifiers.</param>
|
/// <param name="metadata">The build metadata identifiers.</param>
|
||||||
public SemanticVersion(ulong major, ulong minor, ulong patch, string? prerelease, string? metadata)
|
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;
|
Major = major;
|
||||||
Minor = minor;
|
Minor = minor;
|
||||||
|
|
@ -91,20 +104,20 @@ public readonly partial struct SemanticVersion : IEquatable<SemanticVersion>
|
||||||
public ulong Patch { get; }
|
public ulong Patch { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the prerelease label of the semantic version. This value is an optional string
|
/// Gets the prerelease identifiers of the semantic version. This value is an
|
||||||
/// indicating a version that is still in development or not yet considered stable.
|
/// <see cref="IdentifierList"/> indicating a version that is still in development or not yet considered stable.
|
||||||
/// Examples of prerelease labels include "alpha", "beta", or "rc".
|
/// Examples of prerelease identifiers include "alpha", "beta", or "rc".
|
||||||
/// When present, the prerelease label is used to differentiate between versions with
|
/// When present, the prerelease identifiers are used to differentiate between versions with
|
||||||
/// the same major, minor, and patch numbers.
|
/// the same major, minor, and patch numbers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Prerelease { get; }
|
public IdentifierList Prerelease { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the build metadata associated with the semantic version. This optional value provides
|
/// 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
|
/// additional information about the build, such as build number, commit hash, or other relevant
|
||||||
/// details. It does not affect the precedence of the version.
|
/// details. It does not affect the precedence of the version.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Metadata { get; }
|
public IdentifierList Metadata { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool Equals(SemanticVersion other)
|
public bool Equals(SemanticVersion other)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace Geekeey.SemVer;
|
namespace Geekeey.SemVer;
|
||||||
|
|
||||||
|
|
@ -111,7 +112,7 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
||||||
return v1.Major == v2.Major &&
|
return v1.Major == v2.Major &&
|
||||||
v1.Minor == v2.Minor &&
|
v1.Minor == v2.Minor &&
|
||||||
v1.Patch == v2.Patch &&
|
v1.Patch == v2.Patch &&
|
||||||
string.Equals(v1.Prerelease, v2.Prerelease, StringComparison.Ordinal);
|
v1.Prerelease.Equals(v2.Prerelease);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int GetHashCode(SemanticVersion? v)
|
public override int GetHashCode(SemanticVersion? v)
|
||||||
|
|
@ -192,8 +193,8 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
||||||
return v1.Major == v2.Major &&
|
return v1.Major == v2.Major &&
|
||||||
v1.Minor == v2.Minor &&
|
v1.Minor == v2.Minor &&
|
||||||
v1.Patch == v2.Patch &&
|
v1.Patch == v2.Patch &&
|
||||||
string.Equals(v1.Prerelease, v2.Prerelease, StringComparison.Ordinal) &&
|
v1.Prerelease.Equals(v2.Prerelease) &&
|
||||||
string.Equals(v1.Metadata, v2.Metadata, StringComparison.Ordinal);
|
v1.Metadata.Equals(v2.Metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int GetHashCode(SemanticVersion? v)
|
public override int GetHashCode(SemanticVersion? v)
|
||||||
|
|
@ -210,38 +211,55 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
||||||
public override int Compare(SemanticVersion? x, SemanticVersion? y)
|
public override int Compare(SemanticVersion? x, SemanticVersion? y)
|
||||||
{
|
{
|
||||||
var cmp = Priority.Compare(x, 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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (x is null)
|
if (x.IsEmpty)
|
||||||
{
|
{
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (y is null)
|
if (y.IsEmpty)
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var xParts = x.Split('.');
|
var xEnumerator = x.GetEnumerator();
|
||||||
var yParts = y.Split('.');
|
var yEnumerator = y.GetEnumerator();
|
||||||
|
|
||||||
var length = Math.Min(xParts.Length, yParts.Length);
|
while (true)
|
||||||
for (var i = 0; i < length; i++)
|
|
||||||
{
|
{
|
||||||
var xPart = xParts[i];
|
var xHas = xEnumerator.MoveNext();
|
||||||
var yPart = yParts[i];
|
var yHas = yEnumerator.MoveNext();
|
||||||
|
|
||||||
var xIsNumeric = ulong.TryParse(xPart, out var xNum);
|
if (!xHas && !yHas)
|
||||||
var yIsNumeric = ulong.TryParse(yPart, out var yNum);
|
{
|
||||||
|
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)
|
if (xIsNumeric && yIsNumeric)
|
||||||
{
|
{
|
||||||
|
|
@ -261,47 +279,60 @@ public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersio
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var cmp = string.CompareOrdinal(xPart, yPart);
|
var cmp = xPart.SequenceCompareTo(yPart);
|
||||||
if (cmp is not 0)
|
if (cmp is not 0)
|
||||||
{
|
{
|
||||||
return cmp;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (x is null)
|
if (x.IsEmpty)
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (y is null)
|
if (y.IsEmpty)
|
||||||
{
|
{
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var xParts = x.Split('.');
|
var xEnumerator = x.GetEnumerator();
|
||||||
var yParts = y.Split('.');
|
var yEnumerator = y.GetEnumerator();
|
||||||
|
|
||||||
var length = Math.Min(xParts.Length, yParts.Length);
|
while (true)
|
||||||
for (var i = 0; i < length; i++)
|
|
||||||
{
|
{
|
||||||
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)
|
if (cmp is not 0)
|
||||||
{
|
{
|
||||||
return cmp;
|
return cmp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return xParts.Length.CompareTo(yParts.Length);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ public readonly partial struct SemanticVersionRange : ISpanFormattable
|
||||||
return false;
|
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)
|
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)
|
if (hi.Major == lo.Major && hi.Minor == lo.Minor + 1)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue