diff --git a/src/Elastic.Markdown/Helpers/SemVersion.cs b/src/Elastic.Markdown/Helpers/SemVersion.cs new file mode 100644 index 0000000..d59f7ca --- /dev/null +++ b/src/Elastic.Markdown/Helpers/SemVersion.cs @@ -0,0 +1,314 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Elastic.Markdown.Helpers; + +/// +/// A semver2 compatible version. +/// +public sealed class SemVersion : + IEquatable, + IComparable, + IComparable +{ + // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + private static readonly Regex Regex = new(@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"); + + /// + /// The major version part. + /// + public int Major { get; } + + /// + /// The minor version part. + /// + public int Minor { get; } + + /// + /// The patch version part. + /// + public int Patch { get; } + + /// + /// The prerelease version part. + /// + public string Prerelease { get; } + + /// + /// The metadata version part. + /// + public string Metadata { get; } + + /// + /// Initializes a new instance. + /// + /// The major version part. + /// The minor version part. + /// The patch version part. + public SemVersion(int major, int minor, int patch) + { + Major = major; + Minor = minor; + Patch = patch; + Prerelease = string.Empty; + Metadata = string.Empty; + } + + /// + /// Initializes a new instance. + /// + /// The major version part. + /// The minor version part. + /// The patch version part. + /// The prerelease version part. + public SemVersion(int major, int minor, int patch, string? prerelease) + { + Major = major; + Minor = minor; + Patch = patch; + Prerelease = prerelease ?? string.Empty; + Metadata = string.Empty; + } + + /// + /// Initializes a new instance. + /// + /// The major version part. + /// The minor version part. + /// The patch version part. + /// The prerelease version part. + /// The metadata version part. + public SemVersion(int major, int minor, int patch, string? prerelease, string? metadata) + { + Major = major; + Minor = minor; + Patch = patch; + Prerelease = prerelease ?? string.Empty; + Metadata = metadata ?? string.Empty; + } + + /// + /// + /// + /// + /// + /// + public static bool operator ==(SemVersion left, SemVersion right) => Equals(left, right); + + /// + /// + /// + /// + /// + /// + public static bool operator !=(SemVersion left, SemVersion right) => !Equals(left, right); + + /// + /// + /// + /// + /// + /// + public static bool operator >(SemVersion left, SemVersion right) => (left.CompareTo(right) > 0); + + /// + /// + /// + /// + /// + /// + public static bool operator >=(SemVersion left, SemVersion right) => (left == right) || (left > right); + + /// + /// + /// + /// + /// + /// + public static bool operator <(SemVersion left, SemVersion right) => (left.CompareTo(right) < 0); + + /// + /// + /// + /// + /// + /// + public static bool operator <=(SemVersion left, SemVersion right) => (left == right) || (left < right); + + /// + /// Tries to initialize a new instance from the given string. + /// + /// The semver2 compatible version string. + /// The parsed instance. + /// True if the passed string is a valid semver2 version string or false, if not. + public static bool TryParse(string input, [NotNullWhen(true)] out SemVersion? version) + { + version = null; + + var match = Regex.Match(input); + if (!match.Success) + return false; + + if (!int.TryParse(match.Groups[1].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var major)) + return false; + if (!int.TryParse(match.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var minor)) + return false; + if (!int.TryParse(match.Groups[3].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var patch)) + return false; + + version = new SemVersion(major, minor, patch, match.Groups[4].Value, match.Groups[5].Value); + + return true; + } + + /// + /// Returns a new instance with updated components. Unchanged parts should be set to null. + /// + /// The major version part, or null to keep the current value. + /// The minor version part, or null to keep the current value. + /// The patch version part, or null to keep the current value. + /// The prerelease version part, or null to keep the current value. + /// The metadata version part, or null to keep the current value. + /// + public SemVersion Update(int? major = null, int? minor = null, int? patch = null, string? prerelease = null, string? metadata = null) => + new(major ?? Major, + minor ?? Minor, + patch ?? Patch, + prerelease ?? Prerelease, + metadata ?? Metadata); + + /// + /// Compares the current version to another version in a natural way (by component/part precedence). + /// + /// The to compare to. + /// 0 if both versions are equal, a positive number, if the other version is lower or a negative number if the other version is higher. + public int CompareByPrecedence(SemVersion? other) + { + if (ReferenceEquals(other, null)) + return 1; + + var result = Major.CompareTo(other.Major); + if (result != 0) + return result; + + result = Minor.CompareTo(other.Minor); + if (result != 0) + return result; + + result = Patch.CompareTo(other.Patch); + if (result != 0) + return result; + + result = CompareComponent(Prerelease, other.Prerelease, true); + if (result != 0) + return result; + + return CompareComponent(Prerelease, other.Metadata, true); + } + + /// + public int CompareTo(SemVersion? other) + { + if (ReferenceEquals(other, null)) + return 1; + + return CompareByPrecedence(other); + } + + /// + public int CompareTo(object? obj) => CompareTo(obj as SemVersion); + + /// + public bool Equals(SemVersion? other) + { + if (ReferenceEquals(null, other)) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return (Major == other.Major) && (Minor == other.Minor) && (Patch == other.Patch) && + (Prerelease == other.Prerelease) && (Metadata == other.Metadata); + } + + /// + public override bool Equals(object? obj) => ReferenceEquals(this, obj) || obj is SemVersion other && Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = Major; + hashCode = (hashCode * 397) ^ Minor; + hashCode = (hashCode * 397) ^ Patch; + hashCode = (hashCode * 397) ^ Prerelease.GetHashCode(); + hashCode = (hashCode * 397) ^ Metadata.GetHashCode(); + return hashCode; + } + } + + /// + public override string ToString() + { + var version = $"{Major}.{Minor}.{Patch}"; + + if (!string.IsNullOrEmpty(Prerelease)) + version += "-" + Prerelease; + if (!string.IsNullOrEmpty(Metadata)) + version += "+" + Metadata; + + return version; + } + + private static int CompareComponent(string a, string b, bool lower = false) + { + var aEmpty = string.IsNullOrEmpty(a); + var bEmpty = string.IsNullOrEmpty(b); + if (aEmpty && bEmpty) + return 0; + + if (aEmpty) + return lower ? 1 : -1; + if (bEmpty) + return lower ? -1 : 1; + + var aComps = a.Split('.'); + var bComps = b.Split('.'); + + var minLen = Math.Min(aComps.Length, bComps.Length); + for (var i = 0; i < minLen; i++) + { + var ac = aComps[i]; + var bc = bComps[i]; + var isanum = int.TryParse(ac, out var anum); + var isbnum = int.TryParse(bc, out var bnum); + int r; + if (isanum && isbnum) + { + r = anum.CompareTo(bnum); + if (r != 0) + return anum.CompareTo(bnum); + } + else + { + if (isanum) + return -1; + if (isbnum) + return 1; + + r = string.CompareOrdinal(ac, bc); + if (r != 0) + return r; + } + } + + return aComps.Length.CompareTo(bComps.Length); + } +} + diff --git a/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs b/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs index b145f50..ad225e7 100644 --- a/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs @@ -1,6 +1,9 @@ // Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information + +using Elastic.Markdown.Helpers; + namespace Elastic.Markdown.Myst.Directives; public class VersionBlock(DirectiveBlockParser parser, string directive, Dictionary properties) @@ -8,20 +11,30 @@ public class VersionBlock(DirectiveBlockParser parser, string directive, Diction { public override string Directive => directive; public string Class => directive.Replace("version", ""); + public SemVersion? Version { get; private set; } + + public string Title { get; private set; } = string.Empty; - public string Title + public override void FinalizeAndValidate(ParserContext context) { - get + var tokens = Arguments?.Split(" ", 2, StringSplitOptions.RemoveEmptyEntries) ?? []; + if (tokens.Length < 1) { - var title = Thread.CurrentThread.CurrentCulture.TextInfo.ToTitleCase(directive.Replace("version", "version ")); - if (!string.IsNullOrEmpty(Arguments)) - title += $" {Arguments}"; + EmitError(context, $"{directive} needs exactly 2 arguments: "); + return; + } - return title; + if (!SemVersion.TryParse(tokens[0], out var version)) + { + EmitError(context, $"{tokens[0]} is not a valid version"); + return; } - } - public override void FinalizeAndValidate(ParserContext context) - { + Version = version; + var title = Thread.CurrentThread.CurrentCulture.TextInfo.ToTitleCase(directive.Replace("version", "version ")); + title += $" ({Version})"; + if (tokens.Length > 1 && !string.IsNullOrWhiteSpace(tokens[1])) + title += $": {tokens[1]}"; + Title = title; } } diff --git a/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs b/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs index 5657365..8e0573e 100644 --- a/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs @@ -1,6 +1,8 @@ // Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information + +using Elastic.Markdown.Helpers; using Elastic.Markdown.Myst.Directives; using FluentAssertions; using Markdig.Syntax; @@ -10,7 +12,7 @@ namespace Elastic.Markdown.Tests.Directives; public abstract class VersionTests(ITestOutputHelper output, string directive) : DirectiveTest<VersionBlock>(output, $$""" -```{{{directive}}} +```{{{directive}}} 1.0.1-beta1 more information Version brief summary ``` A regular paragraph. @@ -22,26 +24,29 @@ A regular paragraph. [Fact] public void SetsCorrectDirectiveType() => Block!.Directive.Should().Be(directive); + + [Fact] + public void SetsVersion() => Block!.Version.Should().Be(new SemVersion(1, 0, 1, "beta1")); } public class VersionAddedTests(ITestOutputHelper output) : VersionTests(output, "versionadded") { [Fact] - public void SetsTitle() => Block!.Title.Should().Be("Version Added"); + public void SetsTitle() => Block!.Title.Should().Be("Version Added (1.0.1-beta1): more information"); } public class VersionChangedTests(ITestOutputHelper output) : VersionTests(output, "versionchanged") { [Fact] - public void SetsTitle() => Block!.Title.Should().Be("Version Changed"); + public void SetsTitle() => Block!.Title.Should().Be("Version Changed (1.0.1-beta1): more information"); } public class VersionRemovedTests(ITestOutputHelper output) : VersionTests(output, "versionremoved") { [Fact] - public void SetsTitle() => Block!.Title.Should().Be("Version Removed"); + public void SetsTitle() => Block!.Title.Should().Be("Version Removed (1.0.1-beta1): more information"); } public class VersionDeprectatedTests(ITestOutputHelper output) : VersionTests(output, "deprecated") { [Fact] - public void SetsTitle() => Block!.Title.Should().Be("Deprecated"); + public void SetsTitle() => Block!.Title.Should().Be("Deprecated (1.0.1-beta1): more information"); }