From 50e15afb4f442be5dc2d65358f95d5ddda8f174a Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 23:02:24 +0200 Subject: [PATCH] feat: introduce IdentifierList for prerelease and build-metadata segments Add a value type wrapping the dot-separated prerelease and build-metadata identifiers, replacing the previous nullable string properties on SemanticVersion. Implements IReadOnlyList, zero-alloc ReadOnlySpan enumeration, Ordinal equality with implicit string conversions, a JSON converter, and ISpanParsable/IParsable parsing validated via IdentifierListFormatInfo. Closes #1 --- CHANGELOG.md | 6 + src/semver.tests/IdentifierListTests.cs | 219 ++++++++ src/semver.tests/SemanticVersionTests.cs | 28 +- src/semver/IdentifierList.cs | 516 ++++++++++++++++++ src/semver/SemanticVersion.Formatting.cs | 4 +- src/semver/SemanticVersion.Parsing.cs | 81 +-- src/semver/SemanticVersion.cs | 31 +- src/semver/SemanticVersionComparer.cs | 91 ++- src/semver/SemanticVersionRange.Formatting.cs | 4 +- 9 files changed, 855 insertions(+), 125 deletions(-) create mode 100644 src/semver.tests/IdentifierListTests.cs create mode 100644 src/semver/IdentifierList.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c410ad5..66acd91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/src/semver.tests/IdentifierListTests.cs b/src/semver.tests/IdentifierListTests.cs new file mode 100644 index 0000000..adcf7b4 --- /dev/null +++ b/src/semver.tests/IdentifierListTests.cs @@ -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(); + 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(); + await Assert.That(() => list[-1]).Throws(); + } + + [Test] + public async Task I_can_enumerate_segments_as_spans() + { + var list = new IdentifierList("alpha.beta.1"); + var collected = new List(); + + 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(); + + foreach (var segment in (IEnumerable)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(json); + await Assert.That(roundTripped == list).IsTrue(); + } + + [Test] + public async Task I_can_deserialize_null_json_to_an_empty_list() + { + var roundTripped = JsonSerializer.Deserialize("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(); + await Assert.That(() => IdentifierList.Parse("a.b!".AsSpan())).Throws(); + } + + [Test] + public async Task I_can_expose_default_format_info_allowing_leading_zeroes() + { + await Assert.That(IdentifierListFormatInfo.Default.ForbidLeadingZeroInNumerics).IsFalse(); + } +} diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index c3f9c73..5cba77f 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -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] diff --git a/src/semver/IdentifierList.cs b/src/semver/IdentifierList.cs new file mode 100644 index 0000000..34aa2cb --- /dev/null +++ b/src/semver/IdentifierList.cs @@ -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; + +/// +/// A read-only, dot-separated list of semantic version identifiers (prerelease or build metadata). +/// +/// +/// +/// The value behaves as an implicit holding the original, joined identifier text (for example +/// "alpha.1"). It additionally exposes the individual segments without allocating a string per segment: it +/// implements of and yields of +/// when enumerated with . +/// +/// +/// 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 . +/// +/// +[JsonConverter(typeof(IdentifierListJsonConverter))] +public readonly struct IdentifierList : IReadOnlyList, IEquatable, IEquatable, ISpanParsable +{ + private readonly string? _value; + + /// + /// Gets an that contains no identifiers. + /// + public static readonly IdentifierList Empty = default; + + /// + /// The set of characters that are legal within a single SemVer identifier. + /// + private static readonly SearchValues ValidIdentifierChars = + SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-"); + + /// + /// Initializes a new instance of the struct from a joined identifier string. + /// + /// + /// The dot-separated identifier text, or /empty to create . + /// + /// + /// 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 parsing. + /// + public IdentifierList(string? value) + { + _value = value; + } + + /// + /// Gets a value indicating whether this instance contains no identifiers. + /// + public bool IsEmpty => string.IsNullOrEmpty(_value); + + /// + 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; + } + } + + /// + 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]; + } + } + + /// + /// Returns an enumerator that iterates over the identifiers as of + /// without allocating a string per segment. + /// + /// An positioned before the first identifier. + public Enumerator GetEnumerator() + { + return new Enumerator(_value); + } + + /// + public override string ToString() + { + return _value ?? string.Empty; + } + + /// + public bool Equals(IdentifierList other) + { + return string.Equals(_value ?? string.Empty, other._value ?? string.Empty, StringComparison.Ordinal); + } + + /// + public bool Equals(string? other) + { + return string.Equals(_value ?? string.Empty, other, StringComparison.Ordinal); + } + + /// + public override bool Equals(object? obj) + { + return obj is IdentifierList list ? Equals(list) : obj is string s && Equals(s); + } + + /// + public override int GetHashCode() + { + return (_value ?? string.Empty).GetHashCode(); + } + + /// Determines whether two values are ordinally equal. + public static bool operator ==(IdentifierList left, IdentifierList right) + { + return left.Equals(right); + } + + /// Determines whether two values differ. + public static bool operator !=(IdentifierList left, IdentifierList right) + { + return !left.Equals(right); + } + + /// Determines whether an and a are ordinally equal. + public static bool operator ==(IdentifierList left, string? right) + { + return left.Equals(right); + } + + /// Determines whether an and a differ. + public static bool operator !=(IdentifierList left, string? right) + { + return !left.Equals(right); + } + + /// Determines whether a and an are ordinally equal. + public static bool operator ==(string? left, IdentifierList right) + { + return right.Equals(left); + } + + /// Determines whether a and an differ. + public static bool operator !=(string? left, IdentifierList right) + { + return !right.Equals(left); + } + + /// Defines an implicit conversion from to its joined form. + public static implicit operator string(IdentifierList value) + { + return value._value ?? string.Empty; + } + + /// Defines an implicit conversion from a joined to an . + public static implicit operator IdentifierList(string? value) + { + return new IdentifierList(value); + } + + #region IParsable + + /// Parses a string into an using the default identifier rules. + /// The string to parse. + /// The parsed . + /// is not a valid dot-separated identifier list. + public static IdentifierList Parse(string? s) + { + return Parse(s.AsSpan(), null); + } + + /// + /// Parses a string into an , validating it against the SemVer identifier rules. + /// + /// The string to parse. + /// See . + /// The parsed . + /// is not a valid dot-separated identifier list. + public static IdentifierList Parse(string? s, IFormatProvider? provider) + { + return Parse(s.AsSpan(), provider); + } + + /// Tries to parse a string into an using the default identifier rules. + public static bool TryParse(string? s, out IdentifierList result) + { + return TryParse(s.AsSpan(), null, out result); + } + + /// + /// Tries to parse a string into an . + /// + /// The string to parse. + /// See . + /// The parsed list, or on failure. + /// if was parsed successfully; otherwise . + public static bool TryParse(string? s, IFormatProvider? provider, out IdentifierList result) + { + return TryParse(s.AsSpan(), provider, out result); + } + + #endregion + + /// Parses a span of characters into an using the default identifier rules. + /// The span of characters to parse. + /// The parsed . + /// is not a valid dot-separated identifier list. + public static IdentifierList Parse(ReadOnlySpan s) + { + return Parse(s, null); + } + + /// + /// Parses a span of characters into an , validating it against the SemVer identifier rules. + /// + /// The span of characters to parse. + /// + /// An that supplies an ; its + /// controls whether all-numeric identifiers may carry + /// leading zeroes. When omitted, leading zeroes are allowed (build-metadata rules). + /// + /// The parsed . + /// is not a valid dot-separated identifier list. + public static IdentifierList Parse(ReadOnlySpan 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; + } + + /// Tries to parse a span of characters into an using the default identifier rules. + public static bool TryParse(ReadOnlySpan s, out IdentifierList result) + { + return TryParse(s, null, out result); + } + + /// + /// Tries to parse a span of characters into an . + /// + /// The span of characters to parse. + /// See . + /// The parsed list, or on failure. + /// if was parsed successfully; otherwise . + public static bool TryParse(ReadOnlySpan 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; + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + if (string.IsNullOrEmpty(_value)) + { + return ((IEnumerable)[]).GetEnumerator(); + } + + var enumerator = GetEnumerator(); + var segments = new List(Count); + while (enumerator.MoveNext()) + { + segments.Add(new string(enumerator.Current)); + } + + return segments.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)this).GetEnumerator(); + } + + /// + /// Determines whether the given dot-separated identifier text conforms to the SemVer 2.0.0 identifier rules. + /// + /// The joined identifier text to validate. + /// + /// to reject numeric identifiers with leading zeroes (used for prerelease identifiers); + /// to allow them (used for build metadata identifiers). + /// + /// if every dot-separated segment is a valid SemVer identifier; otherwise . + private static bool IsValidIdentifiers(ReadOnlySpan 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; + } + + /// + /// Enumerates the identifiers of an as of + /// , avoiding per-segment string allocations. + /// + public ref struct Enumerator + { + private readonly string? _value; + private int _next; + + internal Enumerator(string? value) + { + _value = value; + _next = 0; + Current = default; + } + + /// + /// Gets the identifier at the current position as a of . + /// + public ReadOnlySpan Current { get; private set; } + + /// + /// Advances the enumerator to the next identifier. + /// + /// if an identifier is available; otherwise . + 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; + } + } +} + +/// +/// Supplies the SemVer identifier rules used when parsing an . +/// +public sealed class IdentifierListFormatInfo : IFormatProvider +{ + /// + /// Gets an that allows leading zeroes in all-numeric identifiers + /// (the build-metadata rule). + /// + public static readonly IdentifierListFormatInfo Default = new(); + + /// + /// Initializes a new instance of the class. + /// + /// + /// to reject all-numeric identifiers with leading zeroes (the prerelease rule); + /// to allow them (the build-metadata rule). + /// + public IdentifierListFormatInfo(bool forbidLeadingZeroInNumerics = false) + { + ForbidLeadingZeroInNumerics = forbidLeadingZeroInNumerics; + } + + /// + /// Gets a value indicating whether all-numeric identifiers are forbidden from carrying leading zeroes. + /// + public bool ForbidLeadingZeroInNumerics { get; } + + /// + public object? GetFormat(Type? formatType) + { + return formatType == typeof(IdentifierListFormatInfo) ? this : null; + } + + /// + /// Gets the associated with the specified . + /// + /// The format provider, or for . + /// + /// when it is an , the instance it returns from + /// , or otherwise. + /// + 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; + } +} + +/// +/// Converts an to and from its joined JSON representation. +/// +internal sealed class IdentifierListJsonConverter : JsonConverter +{ + /// + 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); + } + + /// + public override void Write(Utf8JsonWriter writer, IdentifierList value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/semver/SemanticVersion.Formatting.cs b/src/semver/SemanticVersion.Formatting.cs index 5634b27..55d5b83 100644 --- a/src/semver/SemanticVersion.Formatting.cs +++ b/src/semver/SemanticVersion.Formatting.cs @@ -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)) { diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs index ab43eb3..dbc28ca 100644 --- a/src/semver/SemanticVersion.Parsing.cs +++ b/src/semver/SemanticVersion.Parsing.cs @@ -31,7 +31,7 @@ public readonly partial struct SemanticVersion : ISpanParsable /// 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); } /// @@ -85,9 +85,6 @@ public readonly partial struct SemanticVersion : ISpanParsable // components = 1: wildcard at minor // components = 2: wildcard at patch // components = 3: no wildcards - private static readonly System.Buffers.SearchValues ValidIdentifierChars = - System.Buffers.SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-"); - internal static bool TryParsePartially(ReadOnlySpan s, out SemanticVersion version, out int components) { version = default; @@ -98,7 +95,7 @@ public readonly partial struct SemanticVersion : ISpanParsable 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 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 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 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 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; - } } diff --git a/src/semver/SemanticVersion.cs b/src/semver/SemanticVersion.cs index 22311b3..c2d9327 100644 --- a/src/semver/SemanticVersion.cs +++ b/src/semver/SemanticVersion.cs @@ -27,7 +27,7 @@ public readonly partial struct SemanticVersion : IEquatable /// /// The major version number. 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 /// The major version number. /// The minor version number. 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 /// The minor version number. /// The patch version number. 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 /// The prerelease identifiers. /// The build metadata identifiers. public SemanticVersion(ulong major, ulong minor, ulong patch, string? prerelease, string? metadata) + : this(major, minor, patch, new IdentifierList(prerelease), new IdentifierList(metadata)) + { + } + + /// + /// Constructs a new instance of the class. + /// + /// The major version number. + /// The minor version number. + /// The patch version number. + /// The prerelease identifiers. + /// The build metadata identifiers. + 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 public ulong Patch { get; } /// - /// 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 + /// 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. /// - public string? Prerelease { get; } + public IdentifierList Prerelease { get; } /// /// 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. /// - public string? Metadata { get; } + public IdentifierList Metadata { get; } /// public bool Equals(SemanticVersion other) diff --git a/src/semver/SemanticVersionComparer.cs b/src/semver/SemanticVersionComparer.cs index a177492..ecf93ae 100644 --- a/src/semver/SemanticVersionComparer.cs +++ b/src/semver/SemanticVersionComparer.cs @@ -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 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) {