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

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

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..]; 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))
{ {

View file

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

View file

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

View file

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

View file

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