wip
Some checks failed
default / dotnet-default-workflow (push) Failing after 33s

This commit is contained in:
Louis Seubert 2026-05-11 22:53:24 +02:00
commit eaca525ec2
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
12 changed files with 2229 additions and 1 deletions

View file

@ -1,2 +1,4 @@
<Solution>
</Solution>
<Project Path="src/semver/Geekeey.SemVer.csproj" />
<Project Path="src/semver.tests/Geekeey.SemVer.Tests.csproj" />
</Solution>

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\semver\Geekeey.SemVer.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,115 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionComparerTests
{
[Test]
[Arguments("1.0.0", "2.0.0", -1)]
[Arguments("2.0.0", "1.0.0", 1)]
[Arguments("1.0.0", "1.1.0", -1)]
[Arguments("1.1.0", "1.0.0", 1)]
[Arguments("1.0.0", "1.0.1", -1)]
[Arguments("1.0.1", "1.0.0", 1)]
[Arguments("1.0.0-alpha", "1.0.0", -1)]
[Arguments("1.0.0", "1.0.0-alpha", 1)]
[Arguments("1.0.0-alpha", "1.0.0-alpha.1", -1)]
[Arguments("1.0.0-alpha.1", "1.0.0-alpha.beta", -1)]
[Arguments("1.0.0-alpha.beta", "1.0.0-beta", -1)]
[Arguments("1.0.0-beta", "1.0.0-beta.2", -1)]
[Arguments("1.0.0-beta.2", "1.0.0-beta.11", -1)]
[Arguments("1.0.0-beta.11", "1.0.0-rc.1", -1)]
[Arguments("1.0.0-rc.1", "1.0.0", -1)]
[Arguments("1.0.0-alpha.5", "1.0.0-alpha.10", -1)]
[Arguments("1.0.0-alpha.10", "1.0.0-alpha.5", 1)]
[Arguments("1.0.0-alpha.beta", "1.0.0-alpha.5", 1)]
[Arguments("1.0.0-alpha.5", "1.0.0-alpha.beta", -1)]
[Arguments("1.0.0-alpha.1.2", "1.0.0-alpha.1.2.3", -1)]
[Arguments("1.0.0-alpha.1.2.3", "1.0.0-alpha.1.2", 1)]
[Arguments("1.0.0-alpha-b", "1.0.0-alpha-a", 1)]
[Arguments("1.0.0-0.3.7", "1.0.0-x.7.z.92", -1)]
[Arguments("1.0.0-x.7.z.92", "1.0.0-0.3.7", 1)]
[Arguments("1.0.0+build.1", "1.0.0+build.2", 0)]
[Arguments("1.0.0-alpha+build.1", "1.0.0-alpha+build.2", 0)]
public async Task I_can_compare_by_precedence(string v1Str, string v2Str, int expected)
{
var v1 = SemanticVersion.Parse(v1Str);
var v2 = SemanticVersion.Parse(v2Str);
var result = SemanticVersionComparer.Priority.Compare(v1, v2);
await Assert.That(Math.Sign(result)).IsEqualTo(expected);
}
[Test]
[Arguments("1.0.0+build.1", "1.0.0+build.2", 0)]
[Arguments("1.0.0-alpha+build.1", "1.0.0-alpha+build.2", 0)]
public async Task I_can_confirm_precedence_ignores_metadata(string v1Str, string v2Str, int expected)
{
var v1 = SemanticVersion.Parse(v1Str);
var v2 = SemanticVersion.Parse(v2Str);
var result = SemanticVersionComparer.Priority.Compare(v1, v2);
await Assert.That(result).IsEqualTo(expected);
}
[Test]
[Arguments("1.0.0", "1.0.0+build.1", -1)]
[Arguments("1.0.0+build.1", "1.0.0", 1)]
[Arguments("1.0.0+build.1", "1.0.0+build.2", -1)]
[Arguments("1.0.0+build.1.1", "1.0.0+build.1", 1)]
[Arguments("1.0.0+a.b", "1.0.0+a.a", 1)]
[Arguments("1.0.0+a.a", "1.0.0+a.b", -1)]
[Arguments("1.0.0+a.b", "1.0.0+a.b.c", -1)]
[Arguments("1.0.0+a.b.c", "1.0.0+a.b", 1)]
[Arguments("1.0.0+1.2.3", "1.0.0+1.2.4", -1)]
[Arguments("1.0.0+1.2.4", "1.0.0+1.2.3", 1)]
[Arguments("1.0.0+01", "1.0.0+1", -1)]
public async Task I_can_compare_by_sort_order(string v1Str, string v2Str, int expected)
{
var v1 = SemanticVersion.Parse(v1Str);
var v2 = SemanticVersion.Parse(v2Str);
var result = SemanticVersionComparer.SortOrder.Compare(v1, v2);
await Assert.That(Math.Sign(result)).IsEqualTo(expected);
}
[Test]
public async Task I_can_confirm_precedence_equality_ignores_metadata()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha+build.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha+build.2");
await Assert.That(SemanticVersionComparer.Priority.Equals(v1, v2)).IsTrue();
await Assert.That(SemanticVersionComparer.Priority.GetHashCode(v1)).IsEqualTo(SemanticVersionComparer.Priority.GetHashCode(v2));
}
[Test]
public async Task I_can_confirm_sort_order_equality_includes_metadata()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha+build.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha+build.2");
await Assert.That(SemanticVersionComparer.SortOrder.Equals(v1, v2)).IsFalse();
await Assert.That(SemanticVersionComparer.SortOrder.GetHashCode(v1)).IsNotEqualTo(SemanticVersionComparer.SortOrder.GetHashCode(v2));
}
[Test]
public async Task I_can_confirm_equality_for_identical_versions()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha.1");
await Assert.That(SemanticVersionComparer.Priority.Equals(v1, v2)).IsTrue();
await Assert.That(SemanticVersionComparer.SortOrder.Equals(v1, v2)).IsTrue();
await Assert.That(SemanticVersionComparer.Priority.GetHashCode(v1)).IsEqualTo(SemanticVersionComparer.Priority.GetHashCode(v2));
await Assert.That(SemanticVersionComparer.SortOrder.GetHashCode(v1)).IsEqualTo(SemanticVersionComparer.SortOrder.GetHashCode(v2));
}
[Test]
public async Task I_can_confirm_equality_for_different_versions()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha.2");
await Assert.That(SemanticVersionComparer.Priority.Equals(v1, v2)).IsFalse();
await Assert.That(SemanticVersionComparer.SortOrder.Equals(v1, v2)).IsFalse();
}
}

View file

@ -0,0 +1,168 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionRangeTests
{
[Test]
[Arguments("1.2.3", "1.2.3", true)]
[Arguments("1.2.3", "1.2.4", false)]
[Arguments(">1.2.3", "1.2.4", true)]
[Arguments(">1.2.3", "1.2.3", false)]
[Arguments(">=1.2.3", "1.2.3", true)]
[Arguments("<1.2.3", "1.2.2", true)]
[Arguments("<=1.2.3", "1.2.3", true)]
public async Task I_can_satisfy_npm_basic_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("1.2.3 - 2.3.4", "1.2.3", true)]
[Arguments("1.2.3 - 2.3.4", "2.3.4", true)]
[Arguments("1.2.3 - 2.3.4", "1.2.2", false)]
[Arguments("1.2.3 - 2.3.4", "2.3.5", false)]
public async Task I_can_satisfy_npm_hyphen_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("^1.2.3", "1.2.3", true)]
[Arguments("^1.2.3", "1.9.9", true)]
[Arguments("^1.2.3", "2.0.0", false)]
[Arguments("^0.2.3", "0.2.3", true)]
[Arguments("^0.2.3", "0.2.4", true)]
[Arguments("^0.2.3", "0.3.0", false)]
[Arguments("^0.0.3", "0.0.3", true)]
[Arguments("^0.0.3", "0.0.4", false)]
public async Task I_can_satisfy_npm_caret_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("~1.2.3", "1.2.3", true)]
[Arguments("~1.2.3", "1.2.9", true)]
[Arguments("~1.2.3", "1.3.0", false)]
[Arguments("~1.2", "1.2.0", true)]
[Arguments("~1.2", "1.2.9", true)]
[Arguments("~1.2", "1.3.0", false)]
[Arguments("~1", "1.0.0", true)]
[Arguments("~1", "1.9.9", true)]
[Arguments("~1", "2.0.0", false)]
public async Task I_can_satisfy_npm_tilde_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("1.2.x", "1.2.0", true)]
[Arguments("1.2.x", "1.2.9", true)]
[Arguments("1.2.x", "1.3.0", false)]
[Arguments("1.x", "1.0.0", true)]
[Arguments("1.x", "1.9.9", true)]
[Arguments("1.x", "2.0.0", false)]
[Arguments("*", "0.0.0", true)]
[Arguments("*", "9.9.9", true)]
public async Task I_can_satisfy_npm_wildcard_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments(">1.2.3 || <1.0.0", "1.2.4", true)]
[Arguments(">1.2.3 || <1.0.0", "0.9.9", true)]
[Arguments(">1.2.3 || <1.0.0", "1.1.0", false)]
[Arguments(">1.0.0 <=3.2.6 || >=1.0.0 <6.0.0", "2.0.0", true)]
[Arguments(">1.0.0 <=3.2.6 || >=1.0.0 <6.0.0", "5.0.0", true)]
[Arguments(">1.0.0 <=3.2.6 || >=1.0.0 <6.0.0", "7.0.0", false)]
public async Task I_can_satisfy_npm_or_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("^6.2.3-alpha.1", "6.2.3-alpha.1", true)]
[Arguments("^6.2.3-alpha.1", "6.2.3-beta", true)]
[Arguments("^6.2.3-alpha.1", "6.2.2", false)]
[Arguments("^6.2.3-alpha.1", "7.0.0", false)]
public async Task I_can_satisfy_complex_npm_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("[1.2.3]", "1.2.3", true)]
[Arguments("[1.2.3]", "1.2.4", false)]
[Arguments("[1.2.3, 1.4.0)", "1.2.3", true)]
[Arguments("[1.2.3, 1.4.0)", "1.3.9", true)]
[Arguments("[1.2.3, 1.4.0)", "1.4.0", false)]
[Arguments("(1.2.3, 1.4.0]", "1.2.3", false)]
[Arguments("(1.2.3, 1.4.0]", "1.4.0", true)]
[Arguments("[1.2.3,)", "1.2.3", true)]
[Arguments("[1.2.3,)", "9.9.9", true)]
[Arguments("(,1.4.0]", "1.4.0", true)]
[Arguments("(,1.4.0]", "0.0.0", true)]
public async Task I_can_satisfy_maven_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("[1.2,1.3],[1.5,)", "1.2.0", true)]
[Arguments("[1.2,1.3],[1.5,)", "1.4.0", false)]
[Arguments("[1.2,1.3],[1.5,)", "1.5.0", true)]
public async Task I_can_satisfy_maven_or_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
[Arguments("^1.2.3", "1.2.3-alpha", false)]
[Arguments("^1.2.3-alpha", "1.2.3-beta", true)]
[Arguments("^1.2.3-alpha", "1.2.4", true)]
[Arguments("^1.2.3-alpha", "1.3.0-alpha", false)]
public async Task I_can_satisfy_prerelease_rules(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Satisfies(v)).IsEqualTo(expected);
}
[Test]
public async Task I_can_serialize_to_json()
{
var r = SemanticVersionRange.Parse("^1.2.3");
var json = System.Text.Json.JsonSerializer.Serialize(r);
await Assert.That(json).IsEqualTo("\"^1.2.3\"");
}
[Test]
public async Task I_can_deserialize_from_json()
{
var json = "\"^1.2.3\"";
var r = System.Text.Json.JsonSerializer.Deserialize<SemanticVersionRange>(json);
await Assert.That(r.ToString()).IsEqualTo("^1.2.3");
await Assert.That(r.Satisfies(new SemanticVersion(1, 2, 4))).IsTrue();
}
}

View file

@ -0,0 +1,250 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionTests
{
private static readonly JsonSerializerOptions RelaxedOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
[Test]
[Arguments(0, 0, 0, 0)]
[Arguments(1, 1, 1, 0)]
[Arguments(1, 2, 0, 0)]
[Arguments(45, 2, 4, 0)]
public async Task I_can_create_semver_from_version(int major, int minor, int build, int revision)
{
var version = new Version(major, minor, build, revision);
var v = new SemanticVersion(version);
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();
}
[Test]
[Arguments(1UL)]
[Arguments(0UL)]
public async Task I_can_create_semver_with_major(ulong major)
{
var v = new SemanticVersion(major);
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();
}
[Test]
[Arguments(1UL, 2UL)]
public async Task I_can_create_semver_with_major_and_minor(ulong major, ulong minor)
{
var v = new SemanticVersion(major, minor);
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();
}
[Test]
[Arguments(1UL, 2UL, 3UL)]
public async Task I_can_create_semver_with_major_minor_and_patch(ulong major, ulong minor, ulong patch)
{
var v = new SemanticVersion(major, minor, patch);
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();
}
[Test]
[Arguments(1UL, 2UL, 3UL, "alpha", "build.1")]
[Arguments(1UL, 2UL, 3UL, null, null)]
public async Task I_can_create_semver_with_all_components(ulong major, ulong minor, ulong patch, string? prerelease, string? metadata)
{
var v = new SemanticVersion(major, minor, patch, prerelease, metadata);
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);
}
[Test]
public async Task I_can_compare_identical_versions()
{
var v1 = new SemanticVersion(1, 2, 3);
var v2 = new SemanticVersion(1, 2, 3);
await Assert.That(v1.CompareTo(v2)).IsEqualTo(0);
}
[Test]
public async Task I_can_compare_with_objects()
{
var v1 = new SemanticVersion(1, 2, 3);
object v2 = new SemanticVersion(1, 2, 3);
await Assert.That(v1.CompareTo(v2)).IsEqualTo(0);
await Assert.That(v1.CompareTo(null)).IsEqualTo(1);
await Assert.That(v1.CompareTo("not a version")).IsEqualTo(1);
}
[Test]
public async Task I_can_confirm_operators_work()
{
var v1 = new SemanticVersion(1, 2, 3);
var v2 = new SemanticVersion(2, 0, 0);
await Assert.That(v1 < v2).IsTrue();
await Assert.That(v1 <= v2).IsTrue();
await Assert.That(v1 > v2).IsFalse();
await Assert.That(v1 >= v2).IsFalse();
}
[Test]
public async Task I_can_parse_from_string()
{
var result = SemanticVersion.Parse("1.2.3");
await Assert.That(result.Major).IsEqualTo(1UL);
await Assert.That(result.Minor).IsEqualTo(2UL);
await Assert.That(result.Patch).IsEqualTo(3UL);
}
[Test]
public async Task I_can_try_parse_from_string()
{
var success = SemanticVersion.TryParse("1.2.3-alpha+build.1", out var result);
await Assert.That(success).IsTrue();
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");
}
[Test]
public async Task I_can_parse_from_utf8()
{
var utf8 = "2.0.0-rc.1"u8;
var result = SemanticVersion.Parse(utf8);
await Assert.That(result.Major).IsEqualTo(2UL);
await Assert.That(result.Minor).IsEqualTo(0UL);
await Assert.That(result.Patch).IsEqualTo(0UL);
await Assert.That(result.Prerelease).IsEqualTo("rc.1");
}
[Test]
[Arguments(1, 2, 3, null, null, "1.2.3")]
[Arguments(1, 2, 3, "alpha", null, "1.2.3-alpha")]
[Arguments(1, 2, 3, "alpha", "build.1", "1.2.3-alpha+build.1")]
public async Task I_can_get_string_representation(ulong major, ulong minor, ulong patch, string? pre, string? meta, string expected)
{
var v = new SemanticVersion(major, minor, patch, pre, meta);
// Note: format "f" is needed for full version including metadata based on code
var format = string.IsNullOrEmpty(meta) ? (string.IsNullOrEmpty(pre) ? null : "s") : "f";
await Assert.That(v.ToString(format, null)).IsEqualTo(expected);
}
[Test]
public async Task I_can_format_to_chars()
{
var v = new SemanticVersion(1, 2, 3, "beta", "456");
var dest = new char[32];
var success = v.TryFormat(dest, out var charsWritten, "f", null);
await Assert.That(success).IsTrue();
await Assert.That(new string(dest[..charsWritten])).IsEqualTo("1.2.3-beta+456");
}
[Test]
public async Task I_can_format_to_utf8()
{
var v = new SemanticVersion(1, 2, 3, "beta", "456");
var dest = new byte[32];
var success = v.TryFormat(dest, out var bytesWritten, "f", null);
await Assert.That(success).IsTrue();
await Assert.That(Encoding.UTF8.GetString(dest[..bytesWritten])).IsEqualTo("1.2.3-beta+456");
}
[Test]
[Arguments(1, 2, 3, null, null, "\"1.2.3\"")]
[Arguments(1, 2, 3, "alpha", null, "\"1.2.3-alpha\"")]
[Arguments(1, 2, 3, "alpha", "build.1", "\"1.2.3-alpha+build.1\"")]
public async Task I_can_serialize_to_json(ulong major, ulong minor, ulong patch, string? pre, string? meta, string expectedJson)
{
var v = new SemanticVersion(major, minor, patch, pre, meta);
var json = JsonSerializer.Serialize(v, RelaxedOptions);
await Assert.That(json).IsEqualTo(expectedJson);
}
[Test]
public async Task I_can_deserialize_from_json()
{
var json = "\"1.2.3-beta+789\"";
var v = JsonSerializer.Deserialize<SemanticVersion>(json);
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");
}
[Test]
public async Task I_can_handle_invalid_json_token()
{
var json = "123"; // Number instead of string
await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json))
.Throws<JsonException>()
.WithMessage("Expected string");
}
[Test]
public async Task I_can_serialize_as_part_of_object()
{
var obj = new
{
Version = new SemanticVersion(1, 0, 0, "rc.1", "metadata")
};
var json = JsonSerializer.Serialize(obj, RelaxedOptions);
await Assert.That(json).IsEqualTo("{\"Version\":\"1.0.0-rc.1+metadata\"}");
}
[Test]
public async Task I_can_deserialize_null()
{
var json = "null";
var v = JsonSerializer.Deserialize<SemanticVersion>(json);
await Assert.That(v).IsEqualTo(default(SemanticVersion));
}
[Test]
public async Task I_can_deserialize_empty_string()
{
var json = "\"\"";
await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json))
.Throws<JsonException>();
}
}

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/semver/src/branch/main/src/semver</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,510 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Unicode;
namespace Geekeey.SemVer;
/// <summary>
/// Represents a semantic version that adheres to the Semantic Versioning 2.0.0 specification.
/// </summary>
[JsonConverter(typeof(SemanticVersionJsonConverter))]
public readonly partial record struct SemanticVersion
{
/// <summary>
/// Converts a <see cref="Version"/> into the equivalent semantic version.
/// </summary>
/// <param name="version">The version to be converted to a semantic version.</param>
/// <remarks>
/// <see cref="Version"/> numbers have the form <em>major</em>.<em>minor</em>[.<em>build</em>[.<em>revision</em>]]
/// where square brackets ('[' and ']') indicate optional components. The first three parts are converted to the
/// major, minor, and patch version numbers of a semantic version.
/// </remarks>
public SemanticVersion(Version version)
: this((ulong)version.Major, (ulong)version.Minor, (ulong)version.Build)
{
}
/// <summary>
/// Constructs a new instance of the <see cref="SemanticVersion" /> class.
/// </summary>
/// <param name="major">The major version number.</param>
public SemanticVersion(ulong major)
: this(major, 0, 0, default, default)
{
}
/// <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>
public SemanticVersion(ulong major, ulong minor)
: this(major, minor, 0, default, default)
{
}
/// <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>
public SemanticVersion(ulong major, ulong minor, ulong patch)
: this(major, minor, patch, default, default)
{
}
/// <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, string? prerelease, string? metadata)
{
Major = major;
Minor = minor;
Patch = patch;
Prerelease = prerelease;
Metadata = metadata;
}
/// <summary>
/// Gets the major version number of the semantic version. This value is a non-negative integer
/// representing the most significant component of the version, typically incremented when
/// making incompatible API changes.
/// </summary>
public ulong Major { get; }
/// <summary>
/// Gets the minor version number of the semantic version. This value is a non-negative integer
/// representing the second most significant component of the version, typically incremented
/// when adding backward-compatible functionalities.
/// </summary>
public ulong Minor { get; }
/// <summary>
/// Gets the patch version number of the semantic version. This value is a non-negative integer
/// representing the least significant component of the version, typically incremented for backwards-compatible
/// bug fixes or other small changes.
/// </summary>
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
/// the same major, minor, and patch numbers.
/// </summary>
public string? 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; }
/// <inheritdoc />
public override string ToString()
{
return ToString(null, null);
}
}
public readonly partial record struct SemanticVersion : IComparable, IComparable<SemanticVersion>
{
/// <inheritdoc />
public int CompareTo(object? obj)
{
return obj is not SemanticVersion other ? 1 : CompareTo(other);
}
/// <inheritdoc />
public int CompareTo(SemanticVersion other)
{
return SemanticVersionComparer.Priority.Compare(this, other);
}
/// <summary>
/// Determines whether one version is less than another version.
/// </summary>
public static bool operator <(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) < 0;
}
/// <summary>
/// Determines whether one version is less than or equal to another version.
/// </summary>
public static bool operator <=(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) <= 0;
}
/// <summary>
/// Determines whether one version is greater than another version.
/// </summary>
public static bool operator >(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) > 0;
}
/// <summary>
/// Determines whether one version is greater than or equal to another version.
/// </summary>
public static bool operator >=(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) >= 0;
}
}
public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVersion>, IUtf8SpanParsable<SemanticVersion>
{
#region IParsable
/// <summary>
/// Parses a string into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="Parse(string, IFormatProvider)"/>
public static SemanticVersion Parse(string s)
{
return Parse(s.AsSpan(), null);
}
/// <inheritdoc />
public static SemanticVersion Parse(string s, IFormatProvider? provider)
{
return Parse(s.AsSpan(), provider);
}
/// <summary>
/// Tries to parse a string into a <see cref="SemanticVersion"/>.
/// </summary>
/// <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);
}
/// <inheritdoc />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result)
{
return TryParse(s.AsSpan(), provider, out result);
}
#endregion
#region ISpanParsable
/// <summary>
/// Parses a span of characters into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="Parse(ReadOnlySpan{char}, IFormatProvider)"/>
public static SemanticVersion Parse(ReadOnlySpan<char> s)
{
return Parse(s, null);
}
/// <inheritdoc />
public static SemanticVersion Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
{
if (!TryParse(s, provider, out var version))
{
throw new FormatException($"The input string '{s}' was not in a correct format.");
}
return version;
}
/// <summary>
/// Tries to parse a span of characters into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="TryParse(ReadOnlySpan{char}, IFormatProvider, out SemanticVersion)"/>
public static bool TryParse(ReadOnlySpan<char> s, [MaybeNullWhen(false)] out SemanticVersion result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result)
{
result = default;
if (s.IsEmpty)
{
return false;
}
var metadata = default(string);
var plusIndex = s.IndexOf('+');
if (plusIndex >= 0)
{
metadata = s[(plusIndex + 1)..].ToString();
s = s[..plusIndex];
}
var prerelease = default(string);
var dashIndex = s.IndexOf('-');
if (dashIndex >= 0)
{
prerelease = s[(dashIndex + 1)..].ToString();
s = s[..dashIndex];
}
var firstDot = s.IndexOf('.');
if (firstDot < 0)
{
return false;
}
if (!ulong.TryParse(s[..firstDot], NumberStyles.None, CultureInfo.InvariantCulture, out var major))
{
return false;
}
s = s[(firstDot + 1)..];
var secondDot = s.IndexOf('.');
if (secondDot < 0)
{
return false;
}
if (!ulong.TryParse(s[..secondDot], NumberStyles.None, CultureInfo.InvariantCulture, out var minor))
{
return false;
}
s = s[(secondDot + 1)..];
if (!ulong.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var patch))
{
return false;
}
result = new SemanticVersion(major, minor, patch, prerelease, metadata);
return true;
}
#endregion
#region IUtf8SpanParsable
/// <summary>
/// Parses a span of UTF-8 bytes into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="Parse(ReadOnlySpan{byte}, IFormatProvider)"/>
public static SemanticVersion Parse(ReadOnlySpan<byte> utf8Text)
{
return Parse(utf8Text, null);
}
/// <inheritdoc />
public static SemanticVersion Parse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider)
{
if (!TryParse(utf8Text, provider, out var version))
{
throw new FormatException($"The input string '{Encoding.UTF8.GetString(utf8Text)}' was not in a correct format.");
}
return version;
}
/// <summary>
/// Tries to parse a span of UTF-8 bytes into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="TryParse(ReadOnlySpan{byte}, IFormatProvider, out SemanticVersion)"/>
public static bool TryParse(ReadOnlySpan<byte> utf8Text, [MaybeNullWhen(false)] out SemanticVersion result)
{
return TryParse(utf8Text, null, out result);
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result)
{
if (utf8Text.IsEmpty)
{
result = default;
return false;
}
var charCount = Encoding.UTF8.GetCharCount(utf8Text);
var chars = charCount <= 256 ? stackalloc char[charCount] : new char[charCount];
Encoding.UTF8.GetChars(utf8Text, chars);
return TryParse(chars, provider, out result);
}
#endregion
}
public readonly partial record struct SemanticVersion : ISpanFormattable, IUtf8SpanFormattable
{
internal int RequiredBufferSize
{
get
{
var length = 0;
length += ulong.CountDigits(Major) + 1;
length += ulong.CountDigits(Minor) + 1;
length += ulong.CountDigits(Patch);
length += Prerelease is { Length: > 0 } ? 1 + Prerelease.Length : 0;
length += Metadata is { Length: > 0 } ? 1 + Metadata.Length : 0;
return length;
}
}
#region IFormattable
/// <inheritdoc />
/// <summary>
/// <para>s - Default SemVer - [1.2.3-beta.4]</para>
/// <para>f - Full SemVer - [1.2.3-beta.4+5]</para>
/// <para>r - Just the SemVer part relevant for compatibility comparison - [1.2.3]</para>
/// </summary>
public string ToString(string? format, IFormatProvider? formatProvider)
{
var capacity = RequiredBufferSize;
var shared = capacity > 256 ? ArrayPool<char>.Shared.Rent(capacity) : null;
try
{
scoped var buffer = shared.AsSpan();
if (shared is null)
{
buffer = stackalloc char[capacity];
}
_ = TryFormat(buffer, out var bytesWritten, format, formatProvider);
return new string(buffer[..bytesWritten]);
}
finally
{
if (shared is not null)
{
ArrayPool<char>.Shared.Return(shared);
}
}
}
#endregion
#region ISpanFormattable
/// <summary>
/// Tries to format the semantic version into the specified span of characters.
/// </summary>
/// <see cref="TryFormat(Span{char},out int,ReadOnlySpan{char},IFormatProvider)">ISpanFormattable.TryFormat</see>
public bool TryFormat(Span<char> destination, out int charsWritten)
{
return TryFormat(destination, out charsWritten, default, null);
}
/// <inheritdoc />
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
if (!destination.TryWrite(NumberFormatInfo.InvariantInfo, $"{Major}.{Minor}.{Patch}", out charsWritten))
{
charsWritten = 0;
return false;
}
destination = destination[charsWritten..];
if (Prerelease is { Length: > 0 } && format is "s" or "f")
{
destination[0] = '-';
if (!Prerelease.AsSpan().TryCopyTo(destination[1..]))
{
charsWritten = 0;
return false;
}
destination = destination[(Prerelease.Length + 1)..];
charsWritten += Prerelease.Length + 1;
}
if (Metadata is { Length: > 0 } && format is "f")
{
destination[0] = '+';
if (!Metadata.AsSpan().TryCopyTo(destination[1..]))
{
charsWritten = 0;
return false;
}
destination = destination[(Metadata.Length + 1)..];
charsWritten += Metadata.Length + 1;
}
_ = destination;
return true;
}
#endregion
#region IUtf8SpanFormattable
/// <summary>
/// Tries to format the semantic version into the specified span of UTF-8 bytes.
/// </summary>
/// <see cref="TryFormat(Span{byte},out int,ReadOnlySpan{char},IFormatProvider)">IUtf8SpanFormattable.TryFormat</see>
public bool TryFormat(Span<byte> utf8Destination, out int bytesWritten)
{
return TryFormat(utf8Destination, out bytesWritten, default, null);
}
/// <inheritdoc />
public bool TryFormat(Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
if (!Utf8.TryWrite(utf8Destination, NumberFormatInfo.InvariantInfo, $"{Major}.{Minor}.{Patch}", out bytesWritten))
{
bytesWritten = 0;
return false;
}
utf8Destination = utf8Destination[bytesWritten..];
if (Prerelease is { Length: > 0 } && format is "s" or "f")
{
utf8Destination[0] = (byte)'-';
if (Utf8.FromUtf16(Prerelease, utf8Destination[1..], out _, out var b) is not OperationStatus.Done)
{
bytesWritten = 0;
return false;
}
utf8Destination = utf8Destination[(b + 1)..];
bytesWritten += b + 1;
}
if (Metadata is { Length: > 0 } && format is "f")
{
utf8Destination[0] = (byte)'+';
if (Utf8.FromUtf16(Metadata, utf8Destination[1..], out _, out var b) is not OperationStatus.Done)
{
bytesWritten = 0;
return false;
}
utf8Destination = utf8Destination[(b + 1)..];
bytesWritten += b + 1;
}
_ = utf8Destination;
return true;
}
#endregion
}

View file

@ -0,0 +1,307 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections;
namespace Geekeey.SemVer;
/// <summary>
/// Provides comparers for <see cref="SemanticVersion"/>.
/// </summary>
public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersion?>, IEqualityComparer, IComparer<SemanticVersion?>, IComparer
{
/// <summary>
/// Gets or sets the comparer that determines the precedence of semantic versions.
/// </summary>
public static SemanticVersionComparer Priority { get; } = new PrecedenceSemanticVersionComparer();
/// <summary>
/// Gets the comparer that determines the sort order of semantic versions.
/// </summary>
public static SemanticVersionComparer SortOrder { get; } = new SortOrderSemanticVersionComparer();
bool IEqualityComparer.Equals(object? x, object? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
if (x is SemanticVersion v1 && y is SemanticVersion v2)
{
return Equals(v1, v2);
}
throw new ArgumentException($"Type of argument is not {nameof(SemanticVersion)}.");
}
/// <inheritdoc/>
public abstract bool Equals(SemanticVersion? x, SemanticVersion? y);
int IEqualityComparer.GetHashCode(object? obj)
{
if (obj is null)
{
return 0;
}
if (obj is SemanticVersion v)
{
return GetHashCode(v);
}
throw new ArgumentException($"Type of argument is not {nameof(SemanticVersion)}.");
}
/// <inheritdoc/>
public abstract int GetHashCode(SemanticVersion? v);
int IComparer.Compare(object? x, object? y)
{
if (x is null && y is null)
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
if (x is SemanticVersion v1 && y is SemanticVersion v2)
{
return Compare(v1, v2);
}
throw new ArgumentException($"Type of argument is not {nameof(SemanticVersion)}.");
}
/// <inheritdoc/>
public abstract int Compare(SemanticVersion? x, SemanticVersion? y);
/// <para>Precedence order is determined by comparing the major, minor, patch, and prerelease portion in order from left to right. Versions that differ only by build metadata have the same precedence. The major, minor, and patch version numbers are compared numerically. A prerelease version precedes a release version.</para>
/// <para>The prerelease portion is compared by comparing each prerelease identifier from left to right. Numeric prerelease identifiers precede alphanumeric identifiers. Numeric identifiers are compared numerically. Alphanumeric identifiers are compared lexically in ASCII sort order. A longer series of prerelease identifiers follows a shorter series if all the preceding identifiers are equal.</para>
private sealed class PrecedenceSemanticVersionComparer : SemanticVersionComparer
{
public override bool Equals(SemanticVersion? x, SemanticVersion? y)
{
if (x is null && y is null)
{
return true;
}
if (x is null || y is null)
{
return false;
}
var v1 = x.Value;
var v2 = y.Value;
return v1.Major == v2.Major &&
v1.Minor == v2.Minor &&
v1.Patch == v2.Patch &&
string.Equals(v1.Prerelease, v2.Prerelease, StringComparison.Ordinal);
}
public override int GetHashCode(SemanticVersion? v)
{
if (v is null)
{
return 0;
}
var ver = v.Value;
return HashCode.Combine(ver.Major, ver.Minor, ver.Patch, ver.Prerelease);
}
public override int Compare(SemanticVersion? x, SemanticVersion? y)
{
if (x is null && y is null)
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var v1 = x.Value;
var v2 = y.Value;
var cmp = v1.Major.CompareTo(v2.Major);
if (cmp is not 0)
{
return cmp;
}
cmp = v1.Minor.CompareTo(v2.Minor);
if (cmp is not 0)
{
return cmp;
}
cmp = v1.Patch.CompareTo(v2.Patch);
if (cmp is not 0)
{
return cmp;
}
return ComparePrerelease(v1.Prerelease, v2.Prerelease);
}
}
/// <remarks>
/// <para>Sort order is consistent with precedence order, but provides an order for versions with the same precedence. Sort order is determined by comparing the major, minor, patch, prerelease portion, and build metadata in order from left to right. The major, minor, and patch version numbers are compared numerically. A prerelease version precedes a release version.</para>
/// <para>The prerelease portion is compared by comparing each prerelease identifier from left to right. Numeric prerelease identifiers precede alphanumeric identifiers. Numeric identifiers are compared numerically. Alphanumeric identifiers are compared lexically in ASCII sort order. A longer series of prerelease identifiers follows a shorter series if all the preceding identifiers are equal.</para>
/// <para>Otherwise, equal versions without build metadata precede those with metadata. The build metadata is compared by comparing each metadata identifier. Identifiers are compared lexically in ASCII sort order. A longer series of metadata identifiers follows a shorter series if all the preceding identifiers are equal.</para>
/// </remarks>
private sealed class SortOrderSemanticVersionComparer : SemanticVersionComparer
{
public override bool Equals(SemanticVersion? x, SemanticVersion? y)
{
if (x is null && y is null)
{
return true;
}
if (x is null || y is null)
{
return false;
}
var v1 = x.Value;
var v2 = y.Value;
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);
}
public override int GetHashCode(SemanticVersion? v)
{
if (v is null)
{
return 0;
}
var ver = v.Value;
return HashCode.Combine(ver.Major, ver.Minor, ver.Patch, ver.Prerelease, ver.Metadata);
}
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);
}
}
private static int ComparePrerelease(string? x, string? y)
{
if (string.Equals(x, y, StringComparison.Ordinal))
{
return 0;
}
if (x is null)
{
return 1;
}
if (y is null)
{
return -1;
}
var xParts = x.Split('.');
var yParts = y.Split('.');
var length = Math.Min(xParts.Length, yParts.Length);
for (var i = 0; i < length; i++)
{
var xPart = xParts[i];
var yPart = yParts[i];
var xIsNumeric = ulong.TryParse(xPart, out var xNum);
var yIsNumeric = ulong.TryParse(yPart, out var yNum);
if (xIsNumeric && yIsNumeric)
{
var cmp = xNum.CompareTo(yNum);
if (cmp is not 0)
{
return cmp;
}
}
else if (xIsNumeric)
{
return -1;
}
else if (yIsNumeric)
{
return 1;
}
else
{
var cmp = string.CompareOrdinal(xPart, yPart);
if (cmp is not 0)
{
return cmp;
}
}
}
return xParts.Length.CompareTo(yParts.Length);
}
private static int CompareMetadata(string? x, string? y)
{
if (string.Equals(x, y, StringComparison.Ordinal))
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var xParts = x.Split('.');
var yParts = y.Split('.');
var length = Math.Min(xParts.Length, yParts.Length);
for (var i = 0; i < length; i++)
{
var cmp = string.CompareOrdinal(xParts[i], yParts[i]);
if (cmp is not 0)
{
return cmp;
}
}
return xParts.Length.CompareTo(yParts.Length);
}
}

View file

@ -0,0 +1,61 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Geekeey.SemVer;
internal sealed class SemanticVersionJsonConverter : JsonConverter<SemanticVersion>
{
private const int StackBufferThreshold = 256;
public override SemanticVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.Null)
{
return default;
}
if (reader.TokenType is not JsonTokenType.String)
{
throw new JsonException("Expected string");
}
try
{
return SemanticVersion.Parse(reader.ValueSpan);
}
catch (FormatException e)
{
throw new JsonException(e.Message, e);
}
}
public override void Write(Utf8JsonWriter writer, SemanticVersion value, JsonSerializerOptions options)
{
var capacity = value.RequiredBufferSize;
var shared = capacity > StackBufferThreshold ? ArrayPool<byte>.Shared.Rent(capacity) : null;
try
{
scoped var buffer = shared.AsSpan();
if (shared is null)
{
buffer = stackalloc byte[capacity];
}
_ = value.TryFormat(buffer, out var bytesWritten, "f", null);
writer.WriteStringValue(buffer[..bytesWritten]);
}
finally
{
if (shared is not null)
{
ArrayPool<byte>.Shared.Return(shared);
}
}
}
}

View file

@ -0,0 +1,616 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Unicode;
namespace Geekeey.SemVer;
/// <summary>
/// Represents a semantic version range.
/// </summary>
[JsonConverter(typeof(SemanticVersionRangeJsonConverter))]
public readonly partial record struct SemanticVersionRange : ISpanParsable<SemanticVersionRange>, IUtf8SpanParsable<SemanticVersionRange>, ISpanFormattable, IUtf8SpanFormattable
{
private readonly VersionConstraint[][]? _sets;
private readonly string? _originalString;
private SemanticVersionRange(VersionConstraint[][] sets, string originalString)
{
_sets = sets;
_originalString = originalString;
}
/// <summary>
/// Determines whether the specified version satisfies the range.
/// </summary>
/// <param name="version">The version to check.</param>
/// <returns><c>true</c> if the version satisfies the range; otherwise, <c>false</c>.</returns>
public bool Satisfies(SemanticVersion version)
{
if (_sets == null || _sets.Length == 0)
{
return true;
}
foreach (var set in _sets)
{
if (IsSatisfiedBySet(set, version))
{
return true;
}
}
return false;
}
private static bool IsSatisfiedBySet(VersionConstraint[] set, SemanticVersion version)
{
if (version.Prerelease != null)
{
var hasPrereleaseMatch = false;
foreach (var constraint in set)
{
if (constraint.Version.Prerelease != null &&
constraint.Version.Major == version.Major &&
constraint.Version.Minor == version.Minor &&
constraint.Version.Patch == version.Patch)
{
hasPrereleaseMatch = true;
break;
}
}
if (!hasPrereleaseMatch)
{
return false;
}
}
foreach (var constraint in set)
{
if (!constraint.Satisfies(version))
{
return false;
}
}
return true;
}
/// <inheritdoc />
public override string ToString()
{
return _originalString ?? "*";
}
internal int RequiredBufferSize => _originalString?.Length ?? 1;
#region IFormattable
/// <inheritdoc />
public string ToString(string? format, IFormatProvider? formatProvider)
{
return ToString();
}
#endregion
#region ISpanFormattable
/// <inheritdoc />
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
var s = ToString();
if (s.Length > destination.Length)
{
charsWritten = 0;
return false;
}
s.AsSpan().CopyTo(destination);
charsWritten = s.Length;
return true;
}
#endregion
#region IUtf8SpanFormattable
/// <inheritdoc />
public bool TryFormat(Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
return Utf8.TryWrite(utf8Destination, $"{ToString()}", out bytesWritten);
}
#endregion
#region IParsable
/// <inheritdoc />
public static SemanticVersionRange Parse(string s, IFormatProvider? provider)
{
return Parse(s.AsSpan(), provider);
}
/// <inheritdoc />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s.AsSpan(), provider, out result);
}
#endregion
#region ISpanParsable
/// <summary>
/// Parses a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
public static SemanticVersionRange Parse(ReadOnlySpan<char> s)
{
return Parse(s, null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
{
if (!TryParse(s, provider, out var range))
{
throw new FormatException($"The input string '{s}' was not in a correct format.");
}
return range;
}
/// <summary>
/// Tries to parse a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
public static bool TryParse(ReadOnlySpan<char> s, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
s = s.Trim();
if (s.IsEmpty)
{
result = new SemanticVersionRange([], "*");
return true;
}
return s[0] is '[' or '(' ? TryParseMaven(s, out result) : TryParseNpm(s, out result);
}
#endregion
#region IUtf8SpanParsable
/// <summary>
/// Parses a span of UTF-8 bytes into a <see cref="SemanticVersionRange"/>.
/// </summary>
public static SemanticVersionRange Parse(ReadOnlySpan<byte> utf8Text)
{
return Parse(utf8Text, null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider)
{
if (!TryParse(utf8Text, provider, out var range))
{
throw new FormatException($"The input string '{Encoding.UTF8.GetString(utf8Text)}' was not in a correct format.");
}
return range;
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<byte> utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
if (utf8Text.IsEmpty)
{
result = new SemanticVersionRange([], "*");
return true;
}
var charCount = Encoding.UTF8.GetCharCount(utf8Text);
var chars = charCount <= 256 ? stackalloc char[charCount] : new char[charCount];
Encoding.UTF8.GetChars(utf8Text, chars);
return TryParse(chars, provider, out result);
}
#endregion
private static bool TryParseMaven(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
result = default;
var original = s.ToString();
var alternatives = new List<VersionConstraint[]>();
var start = 0;
var bracketLevel = 0;
for (var i = 0; i < s.Length; i++)
{
if (s[i] is '[' or '(')
{
bracketLevel++;
}
else if (s[i] is ']' or ')')
{
bracketLevel--;
}
else if (s[i] is ',' && bracketLevel == 0)
{
if (!TryParseSingleMavenRange(s[start..i].Trim(), out var constraints))
{
return false;
}
alternatives.Add(constraints);
start = i + 1;
}
}
if (!TryParseSingleMavenRange(s[start..].Trim(), out var lastConstraints))
{
return false;
}
alternatives.Add(lastConstraints);
result = new SemanticVersionRange([.. alternatives], original);
return true;
}
private static bool TryParseSingleMavenRange(ReadOnlySpan<char> s, [NotNullWhen(true)] out VersionConstraint[]? constraints)
{
constraints = null;
if (s.IsEmpty)
{
return false;
}
if (s[0] is not '[' and not '(')
{
if (!TryParseVersionPartially(s, out var v, out _))
{
return false;
}
constraints = [new VersionConstraint(Comparator.GreaterThanOrEqual, v)];
return true;
}
var last = s.Length - 1;
if (s[last] is not ']' and not ')')
{
return false;
}
var inclusiveStart = s[0] == '[';
var inclusiveEnd = s[last] == ']';
var inner = s[1..last];
var commaIndex = inner.IndexOf(',');
if (commaIndex < 0)
{
if (!inclusiveStart || !inclusiveEnd)
{
return false;
}
if (!TryParseVersionPartially(inner, out var v, out _))
{
return false;
}
constraints = [new VersionConstraint(Comparator.Equal, v)];
return true;
}
var startStr = inner[..commaIndex].Trim();
var endStr = inner[(commaIndex + 1)..].Trim();
var list = new List<VersionConstraint>();
if (!startStr.IsEmpty)
{
if (!TryParseVersionPartially(startStr, out var vStart, out _))
{
return false;
}
list.Add(new VersionConstraint(inclusiveStart ? Comparator.GreaterThanOrEqual : Comparator.GreaterThan, vStart));
}
if (!endStr.IsEmpty)
{
if (!TryParseVersionPartially(endStr, out var vEnd, out _))
{
return false;
}
list.Add(new VersionConstraint(inclusiveEnd ? Comparator.LessThanOrEqual : Comparator.LessThan, vEnd));
}
constraints = [.. list];
return true;
}
private static bool TryParseNpm(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
result = default;
var original = s.ToString();
var alternatives = new List<VersionConstraint[]>();
var start = 0;
while (start < s.Length)
{
var orIndex = s[start..].IndexOf("||".AsSpan());
var part = orIndex < 0 ? s[start..].Trim() : s[start..(start + orIndex)].Trim();
start = orIndex < 0 ? s.Length : start + orIndex + 2;
if (part.IsEmpty)
{
continue;
}
if (!TryParseNpmSet(part, out var constraints))
{
return false;
}
alternatives.Add(constraints);
}
result = new SemanticVersionRange([.. alternatives], original);
return true;
}
private static bool TryParseNpmSet(ReadOnlySpan<char> s, [NotNullWhen(true)] out VersionConstraint[]? constraints)
{
constraints = null;
var list = new List<VersionConstraint>();
var hyphenIndex = s.IndexOf(" - ".AsSpan());
if (hyphenIndex > 0)
{
var v1Str = s[..hyphenIndex].Trim();
var v2Str = s[(hyphenIndex + 3)..].Trim();
if (!SemanticVersion.TryParse(v1Str, out var v1) || !SemanticVersion.TryParse(v2Str, out var v2))
{
return false;
}
constraints =
[
new VersionConstraint(Comparator.GreaterThanOrEqual, v1),
new VersionConstraint(Comparator.LessThanOrEqual, v2)
];
return true;
}
var current = s;
while (!current.IsEmpty)
{
current = current.TrimStart();
if (current.IsEmpty)
{
break;
}
var spaceIndex = current.IndexOf(' ');
var part = spaceIndex < 0 ? current : current[..spaceIndex];
current = spaceIndex < 0 ? default : current[spaceIndex..];
if (IsOperatorOnly(part))
{
current = current.TrimStart();
var nextSpace = current.IndexOf(' ');
var nextPart = nextSpace < 0 ? current : current[..nextSpace];
current = nextSpace < 0 ? default : current[nextSpace..];
if (!TryParseNpmConstraint(part, nextPart, list))
{
return false;
}
}
else
{
if (!TryParseNpmConstraint(default, part, list))
{
return false;
}
}
}
constraints = [.. list];
return true;
}
private static bool IsOperatorOnly(ReadOnlySpan<char> s)
{
return s is ">=" or "<=" or ">" or "<" or "~" or "^" or "=";
}
private static bool TryParseNpmConstraint(ReadOnlySpan<char> op, ReadOnlySpan<char> vStr, List<VersionConstraint> constraints)
{
if (op.IsEmpty)
{
if (vStr.StartsWith(">="))
{
op = ">=";
vStr = vStr[2..];
}
else if (vStr.StartsWith("<="))
{
op = "<=";
vStr = vStr[2..];
}
else if (vStr.StartsWith(">"))
{
op = ">";
vStr = vStr[1..];
}
else if (vStr.StartsWith("<"))
{
op = "<";
vStr = vStr[1..];
}
else if (vStr.StartsWith("^"))
{
op = "^";
vStr = vStr[1..];
}
else if (vStr.StartsWith("~"))
{
op = "~";
vStr = vStr[1..];
}
else if (vStr.StartsWith("="))
{
op = "=";
vStr = vStr[1..];
}
}
vStr = vStr.Trim();
if (vStr is "*" or "x" or "X")
{
constraints.Add(new VersionConstraint(Comparator.Any, default));
return true;
}
if (!TryParseVersionPartially(vStr, out var v, out var components))
{
return false;
}
if (op is "^")
{
constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v));
var endV = v.Major > 0 ? new SemanticVersion(v.Major + 1, 0, 0) : (v.Minor > 0 ? new SemanticVersion(0, v.Minor + 1, 0) : new SemanticVersion(0, 0, v.Patch + 1));
constraints.Add(new VersionConstraint(Comparator.LessThan, endV));
}
else if (op is "~")
{
constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v));
var endV = components >= 2 ? new SemanticVersion(v.Major, v.Minor + 1, 0) : new SemanticVersion(v.Major + 1, 0, 0);
constraints.Add(new VersionConstraint(Comparator.LessThan, endV));
}
else if (op is ">") constraints.Add(new VersionConstraint(Comparator.GreaterThan, v));
else if (op is ">=") constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v));
else if (op is "<") constraints.Add(new VersionConstraint(Comparator.LessThan, v));
else if (op is "<=") constraints.Add(new VersionConstraint(Comparator.LessThanOrEqual, v));
else
{
if (components == 3)
{
constraints.Add(new VersionConstraint(Comparator.Equal, v));
}
else if (components == 2)
{
constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v));
constraints.Add(new VersionConstraint(Comparator.LessThan, new SemanticVersion(v.Major, v.Minor + 1, 0)));
}
else if (components == 1)
{
constraints.Add(new VersionConstraint(Comparator.GreaterThanOrEqual, v));
constraints.Add(new VersionConstraint(Comparator.LessThan, new SemanticVersion(v.Major + 1, 0, 0)));
}
}
return true;
}
private static bool TryParseVersionPartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components)
{
version = default;
components = 0;
var dashIndex = s.IndexOf('-');
var metadataIndex = s.IndexOf('+');
var suffixIndex = dashIndex >= 0 ? (metadataIndex >= 0 ? Math.Min(dashIndex, metadataIndex) : dashIndex) : metadataIndex;
var versionPart = suffixIndex >= 0 ? s[..suffixIndex] : s;
var prerelease = dashIndex >= 0 ? (metadataIndex > dashIndex ? s[(dashIndex + 1)..metadataIndex].ToString() : s[(dashIndex + 1)..].ToString()) : null;
var metadata = metadataIndex >= 0 ? s[(metadataIndex + 1)..].ToString() : null;
ulong major = 0, minor = 0, patch = 0;
var current = versionPart;
var componentCount = 0;
while (!current.IsEmpty && componentCount < 3)
{
var dotIndex = current.IndexOf('.');
var part = dotIndex < 0 ? current : current[..dotIndex];
if (part is "*" or "x" or "X")
{
components = componentCount;
version = componentCount switch
{
1 => new SemanticVersion(major, 0, 0),
2 => new SemanticVersion(major, minor, 0),
_ => default
};
return true;
}
if (!ulong.TryParse(part, out var value))
{
return false;
}
if (componentCount == 0) major = value;
else if (componentCount == 1) minor = value;
else if (componentCount == 2) patch = value;
componentCount++;
if (dotIndex < 0) break;
current = current[(dotIndex + 1)..];
}
components = componentCount;
version = new SemanticVersion(major, minor, patch, prerelease, metadata);
return true;
}
}
internal enum Comparator
{
Any,
Equal,
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
NotEqual
}
internal readonly record struct VersionConstraint(Comparator Comparator, SemanticVersion Version)
{
public bool Satisfies(SemanticVersion version)
{
if (Comparator is Comparator.Any)
{
return true;
}
var cmp = version.CompareTo(Version);
return Comparator switch
{
Comparator.Equal => cmp == 0,
Comparator.NotEqual => cmp != 0,
Comparator.GreaterThan => cmp > 0,
Comparator.GreaterThanOrEqual => cmp >= 0,
Comparator.LessThan => cmp < 0,
Comparator.LessThanOrEqual => cmp <= 0,
_ => false
};
}
}

View file

@ -0,0 +1,37 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Geekeey.SemVer;
internal sealed class SemanticVersionRangeJsonConverter : JsonConverter<SemanticVersionRange>
{
public override SemanticVersionRange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.Null)
{
return default;
}
if (reader.TokenType is not JsonTokenType.String)
{
throw new JsonException("Expected string");
}
try
{
return SemanticVersionRange.Parse(reader.ValueSpan);
}
catch (FormatException e)
{
throw new JsonException(e.Message, e);
}
}
public override void Write(Utf8JsonWriter writer, SemanticVersionRange value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}

View file

@ -0,0 +1,111 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Geekeey.SemVer;
// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Buffers/Text/FormattingHelpers.CountDigits.cs
internal static class FormattingHelpers
{
extension(ulong)
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CountDigits(ulong value)
{
// Map the log2(value) to a power of 10.
ReadOnlySpan<byte> log2ToPow10 =
[
1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5,
6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 10,
10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 13, 14, 14, 14, 15, 15,
15, 16, 16, 16, 16, 17, 17, 17, 18, 18, 18, 19, 19, 19, 19, 20
];
Debug.Assert(log2ToPow10.Length == 64);
nint elementOffset = log2ToPow10[(int)ulong.Log2(value)];
// Read the associated power of 10.
ReadOnlySpan<ulong> powersOf10 =
[
0, // unused entry to avoid needing to subtract
0,
10,
100,
1000,
10000,
100000,
1000000,
10000000,
100000000,
1000000000,
10000000000,
100000000000,
1000000000000,
10000000000000,
100000000000000,
1000000000000000,
10000000000000000,
100000000000000000,
1000000000000000000,
10000000000000000000,
];
Debug.Assert((elementOffset + 1) <= powersOf10.Length);
var powerOf10 = Unsafe.Add(ref MemoryMarshal.GetReference(powersOf10), elementOffset);
// Return the number of digits based on the power of 10, shifted by 1
// if it falls below the threshold.
var index = (int)elementOffset;
return index - (value < powerOf10 ? 1 : 0);
}
}
extension(uint)
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CountDigits(uint value)
{
ReadOnlySpan<long> table =
[
4294967296,
8589934582,
8589934582,
8589934582,
12884901788,
12884901788,
12884901788,
17179868184,
17179868184,
17179868184,
21474826480,
21474826480,
21474826480,
21474826480,
25769703776,
25769703776,
25769703776,
30063771072,
30063771072,
30063771072,
34349738368,
34349738368,
34349738368,
34349738368,
38554705664,
38554705664,
38554705664,
41949672960,
41949672960,
41949672960,
42949672960,
42949672960,
];
Debug.Assert(table.Length == 32, "Every result of uint.Log2(value) needs a long entry in the table.");
var tableValue = table[(int)uint.Log2(value)];
return (int)((value + tableValue) >> 32);
}
}
}