feat: introduce IdentifierList for prerelease and build-metadata segments
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:
Louis Seubert 2026-07-12 23:02:24 +02:00
commit 50e15afb4f
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
9 changed files with 855 additions and 125 deletions

View file

@ -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

View 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();
}
}

View file

@ -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]

View 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());
}
}

View file

@ -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))
{

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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);
}
}

View file

@ -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)
{