diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b978f021..9fd2fef6 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -21,7 +21,6 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: | - 3.1.x 6.0.x - name: Restore run: dotnet restore -bl:restore.binlog diff --git a/src/MSBuildLocator.Tests/SemanticVersionParserTests.cs b/src/MSBuildLocator.Tests/SemanticVersionParserTests.cs new file mode 100644 index 00000000..f9e57b3f --- /dev/null +++ b/src/MSBuildLocator.Tests/SemanticVersionParserTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NETCOREAPP + +using Shouldly; +using System.Linq; +using Xunit; + +namespace Microsoft.Build.Locator.Tests +{ + public class SemanticVersionParserTests + { + [Fact] + public void TryParseTest_ReleaseVersion() + { + var version = "7.0.333"; + + var isParsed = SemanticVersionParser.TryParse(version, out var parsedVerion); + + parsedVerion.ShouldNotBeNull(); + isParsed.ShouldBeTrue(); + parsedVerion.Major.ShouldBe(7); + parsedVerion.Minor.ShouldBe(0); + parsedVerion.Patch.ShouldBe(333); + parsedVerion.ReleaseLabels.ShouldBeEmpty(); + } + + [Fact] + public void TryParseTest_PreviewVersion() + { + var version = "8.0.0-preview.6.23329.7"; + + var isParsed = SemanticVersionParser.TryParse(version, out var parsedVerion); + + parsedVerion.ShouldNotBeNull(); + isParsed.ShouldBeTrue(); + parsedVerion.Major.ShouldBe(8); + parsedVerion.Minor.ShouldBe(0); + parsedVerion.Patch.ShouldBe(0); + parsedVerion.ReleaseLabels.ShouldBe(new[] { "preview", "6", "23329", "7" }); + } + + [Fact] + public void TryParseTest_InvalidInput_LeadingZero() + { + var version = "0.0-preview.6"; + + var isParsed = SemanticVersionParser.TryParse(version, out var parsedVerion); + + Assert.Null(parsedVerion); + isParsed.ShouldBeFalse(); + } + + [Fact] + public void TryParseTest_InvalidInput_FourPartsVersion() + { + var version = "5.0.3.4"; + + var isParsed = SemanticVersionParser.TryParse(version, out var parsedVerion); + + Assert.Null(parsedVerion); + isParsed.ShouldBeFalse(); + } + + [Fact] + public void VersionSortingTest_WithPreview() + { + var versions = new[] { "7.0.7", "8.0.0-preview.6.23329.7", "8.0.0-preview.3.23174.8" }; + + var maxVersion = versions.Select(v => SemanticVersionParser.TryParse(v, out var parsedVerion) ? parsedVerion : null).Max(); + + maxVersion.OriginalValue.ShouldBe("8.0.0-preview.6.23329.7"); + } + + [Fact] + public void VersionSortingTest_ReleaseOnly() + { + var versions = new[] { "7.0.7", "3.7.2", "10.0.0" }; + + var maxVersion = versions.Max(v => SemanticVersionParser.TryParse(v, out var parsedVerion) ? parsedVerion : null); + + maxVersion.OriginalValue.ShouldBe("10.0.0"); + } + + [Fact] + public void VersionSortingTest_WithInvalidFolderNames() + { + var versions = new[] { "7.0.7", "3.7.2", "dummy", "5.7.8.9" }; + + var maxVersion = versions.Max(v => SemanticVersionParser.TryParse(v, out var parsedVerion) ? parsedVerion : null); + + maxVersion.OriginalValue.ShouldBe("7.0.7"); + } + + [Fact] + public void VersionSortingTest_WithAllInvalidFolderNames() + { + var versions = new[] { "dummy", "5.7.8.9" }; + + var maxVersion = versions.Max(v => SemanticVersionParser.TryParse(v, out var parsedVerion) ? parsedVerion : null); + + maxVersion.ShouldBeNull(); + } + } +} +#endif \ No newline at end of file diff --git a/src/MSBuildLocator/DotNetSdkLocationHelper.cs b/src/MSBuildLocator/DotNetSdkLocationHelper.cs index fa41f87d..fab49876 100644 --- a/src/MSBuildLocator/DotNetSdkLocationHelper.cs +++ b/src/MSBuildLocator/DotNetSdkLocationHelper.cs @@ -5,28 +5,27 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; +using System.Linq; +using System.Reflection; using System.Runtime.InteropServices; +using System.Runtime.Loader; using System.Text.RegularExpressions; +#nullable enable + namespace Microsoft.Build.Locator { internal static class DotNetSdkLocationHelper { - private static readonly Regex DotNetBasePathRegex = new Regex("Base Path:(.*)$", RegexOptions.Multiline); private static readonly Regex VersionRegex = new Regex(@"^(\d+)\.(\d+)\.(\d+)", RegexOptions.Multiline); - private static readonly Regex SdkRegex = new Regex(@"(\S+) \[(.*?)]$", RegexOptions.Multiline); private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly string ExeName = IsWindows ? "dotnet.exe" : "dotnet"; + private static readonly Lazy DotnetPath = new(() => ResolveDotnetPath()); - public static VisualStudioInstance GetInstance(string dotNetSdkPath) - { - if (string.IsNullOrWhiteSpace(dotNetSdkPath)) - { - return null; - } - - if (!File.Exists(Path.Combine(dotNetSdkPath, "Microsoft.Build.dll"))) + public static VisualStudioInstance? GetInstance(string dotNetSdkPath) + { + if (string.IsNullOrWhiteSpace(dotNetSdkPath) || !File.Exists(Path.Combine(dotNetSdkPath, "Microsoft.Build.dll"))) { return null; } @@ -51,7 +50,7 @@ public static VisualStudioInstance GetInstance(string dotNetSdkPath) { return null; } - + // Components of the SDK often have dependencies on the runtime they shipped with, including that several tasks that shipped // in the .NET 5 SDK rely on the .NET 5.0 runtime. Assuming the runtime that shipped with a particular SDK has the same version, // this ensures that we don't choose an SDK that doesn't work with the runtime of the chosen application. This is not guaranteed @@ -75,138 +74,232 @@ public static IEnumerable GetInstances(string workingDirec { var dotnetSdk = GetInstance(basePath); if (dotnetSdk != null) + { yield return dotnetSdk; + } } } - /// - /// This native method call determines the actual location of path, including - /// resolving symbolic links. - /// - private static string realpath(string path) - { - IntPtr ptr = NativeMethods.realpath(path, IntPtr.Zero); - string result = Marshal.PtrToStringAuto(ptr); - NativeMethods.free(ptr); - return result; - } - - private static string FindDotnetFromEnvironmentVariable(string environmentVariable, string exeName) + private static IEnumerable GetDotNetBasePaths(string workingDirectory) { - string dotnet_root = Environment.GetEnvironmentVariable(environmentVariable); - if (!string.IsNullOrEmpty(dotnet_root)) + try { - string fullPathToDotnetFromRoot = Path.Combine(dotnet_root, exeName); - if (File.Exists(fullPathToDotnetFromRoot)) + AddUnmanagedDllResolver(); + + string? bestSDK = GetSdkFromGlobalSettings(workingDirectory); + if (!string.IsNullOrEmpty(bestSDK)) + { + yield return bestSDK; + } + + string[] dotnetPaths = GetAllAvailableSDKs(); + // We want to return the newest SDKs first, however, so iterate over the list in reverse order. + // If basePath is disqualified because it was later + // than the runtime version, this ensures that RegisterDefaults will return the latest valid + // SDK instead of the earliest installed. + for (int i = dotnetPaths.Length - 1; i >= 0; i--) { - if (!IsWindows) + if (dotnetPaths[i] != bestSDK) { - fullPathToDotnetFromRoot = realpath(fullPathToDotnetFromRoot) ?? fullPathToDotnetFromRoot; - return File.Exists(fullPathToDotnetFromRoot) ? Path.GetDirectoryName(fullPathToDotnetFromRoot) : null; + yield return dotnetPaths[i]; } - - return dotnet_root; } } - - return null; + finally + { + RemoveUnmanagedDllResolver(); + } } - private static IEnumerable GetDotNetBasePaths(string workingDirectory) - { - string dotnetPath = null; - string exeName = IsWindows ? "dotnet.exe" : "dotnet"; + private static void AddUnmanagedDllResolver() => ModifyUnmanagedDllResolver(loadContext => loadContext.ResolvingUnmanagedDll += HostFxrResolver); - // First check for the DOTNET_ROOT environment variable, as it's often there as with, for example, dotnet format. - if (IntPtr.Size == 4) + private static void RemoveUnmanagedDllResolver() => ModifyUnmanagedDllResolver(loadContext => loadContext.ResolvingUnmanagedDll -= HostFxrResolver); + + private static void ModifyUnmanagedDllResolver(Action resolverAction) + { + // For Windows hostfxr is loaded in the process. + if (!IsWindows) { - // 32-bit architecture - dotnetPath ??= FindDotnetFromEnvironmentVariable("DOTNET_ROOT(x86)", exeName); + var loadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()); + if (loadContext != null) + { + resolverAction(loadContext); + } } - else if (IntPtr.Size == 8) + } + + private static IntPtr HostFxrResolver(Assembly assembly, string libraryName) + { + var hostFxrLibName = "libhostfxr"; + var libExtension = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "dylib" : "so"; + + if (!hostFxrLibName.Equals(libraryName)) { - // 64-bit architecture - dotnetPath ??= FindDotnetFromEnvironmentVariable("DOTNET_ROOT", exeName); + return IntPtr.Zero; } - if (dotnetPath is null) + var hostFxrRoot = Path.Combine(DotnetPath.Value, "host", "fxr"); + if (Directory.Exists(hostFxrRoot)) { - // We will generally find the dotnet exe on the path, but on linux, it is often just a 'dotnet' symlink (possibly even to more symlinks) that we have to resolve - // to the real dotnet executable. - // This will work as often as just invoking dotnet from the command line, but we can be more confident in finding a dotnet executable by following - // https://github.com/dotnet/designs/blob/main/accepted/2021/install-location-per-architecture.md - // This can be done using the nethost library. We didn't do this previously, so I did not implement this extension. - foreach (string dir in Environment.GetEnvironmentVariable("PATH").Split(Path.PathSeparator)) + // Load hostfxr from the highest version, because it should be backward-compatible + var hostFxrAssemblyDirectory = Directory.GetDirectories(hostFxrRoot) + .Max(str => SemanticVersionParser.TryParse(str, out var version) ? version : null); + + if (hostFxrAssemblyDirectory != null && !string.IsNullOrEmpty(hostFxrAssemblyDirectory.OriginalValue)) { - string filePath = Path.Combine(dir, exeName); - if (File.Exists(filePath)) + var hostFxrAssembly = Path.Combine(hostFxrAssemblyDirectory.OriginalValue, Path.ChangeExtension(hostFxrLibName, libExtension)); + + if (File.Exists(hostFxrAssembly)) { - if (!IsWindows) - { - filePath = realpath(filePath) ?? filePath; - if (File.Exists(filePath)) - { - dotnetPath = Path.GetDirectoryName(filePath); - break; - } - else - { - continue; - } - } - - dotnetPath = dir; - break; + return NativeLibrary.TryLoad(hostFxrAssembly, out var handle) ? handle : IntPtr.Zero; } } } - if (dotnetPath is null) - { - throw new InvalidOperationException("Could not find the dotnet executable. Is it on the PATH?"); - } + return IntPtr.Zero; + } - string bestSDK = null; - int rc = NativeMethods.hostfxr_resolve_sdk2(exe_dir: dotnetPath, working_dir: workingDirectory, flags: 0, result: (key, value) => + private static string SdkResolutionExceptionMessage(string methodName) => $"Failed to find all versions of .NET Core MSBuild. Call to {methodName}. There may be more details in stderr."; + + /// + /// Determines the directory location of the SDK accounting for + /// global.json and multi-level lookup policy. + /// + private static string? GetSdkFromGlobalSettings(string workingDirectory) + { + string? resolvedSdk = null; + int rc = NativeMethods.hostfxr_resolve_sdk2(exe_dir: DotnetPath.Value, working_dir: workingDirectory, flags: 0, result: (key, value) => { if (key == NativeMethods.hostfxr_resolve_sdk2_result_key_t.resolved_sdk_dir) { - bestSDK = value; + resolvedSdk = value; } }); - if (rc == 0 && bestSDK != null) + if (rc != 0) { - yield return bestSDK; + throw new InvalidOperationException(SdkResolutionExceptionMessage(nameof(NativeMethods.hostfxr_resolve_sdk2))); } - else if (rc != 0) + + return resolvedSdk; + } + + private static string ResolveDotnetPath() + { + string? dotnetPath = GetDotnetPathFromROOT(); + + if (string.IsNullOrEmpty(dotnetPath)) { - throw new InvalidOperationException("Failed to find an appropriate version of .NET Core MSBuild. Call to hostfxr_resolve_sdk2 failed. There may be more details in stderr."); + string? dotnetExePath = GetCurrentProcessPath(); + var isRunFromDotnetExecutable = !string.IsNullOrEmpty(dotnetExePath) + && Path.GetFileName(dotnetExePath).Equals(ExeName, StringComparison.InvariantCultureIgnoreCase); + + if (isRunFromDotnetExecutable) + { + dotnetPath = Path.GetDirectoryName(dotnetExePath); + } + else + { + dotnetPath = FindDotnetPathFromEnvVariable("DOTNET_HOST_PATH") + ?? FindDotnetPathFromEnvVariable("DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR") + ?? GetDotnetPathFromPATH(); + } } - string[] paths = null; - rc = NativeMethods.hostfxr_get_available_sdks(exe_dir: dotnetPath, result: (key, value) => + if (string.IsNullOrEmpty(dotnetPath)) { - paths = value; - }); + throw new InvalidOperationException("Could not find the dotnet executable. Is it set on the DOTNET_ROOT?"); + } + + return dotnetPath; + } + + private static string? GetDotnetPathFromROOT() + { + // 32-bit architecture has (x86) suffix + string envVarName = (IntPtr.Size == 4) ? "DOTNET_ROOT(x86)" : "DOTNET_ROOT"; + var dotnetPath = FindDotnetPathFromEnvVariable(envVarName); + + return dotnetPath; + } + + private static string? GetCurrentProcessPath() => Environment.ProcessPath; + + private static string? GetDotnetPathFromPATH() + { + string? dotnetPath = null; + // We will generally find the dotnet exe on the path, but on linux, it is often just a 'dotnet' symlink (possibly even to more symlinks) that we have to resolve + // to the real dotnet executable. + // This will work as often as just invoking dotnet from the command line, but we can be more confident in finding a dotnet executable by following + // https://github.com/dotnet/designs/blob/main/accepted/2021/install-location-per-architecture.md + // This could be done using the nethost library, but this is currently shipped as metadata package (Microsoft.NETCore.DotNetAppHost) and requires the customers + // to specify for resolving runtime assembly. + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty(); + foreach (string dir in paths) + { + string? filePath = ValidatePath(dir); + if (!string.IsNullOrEmpty(filePath)) + { + dotnetPath = filePath; + break; + } + } + + return dotnetPath; + } + + /// + /// Returns the list of all available SDKs ordered by ascending version. + /// + private static string[] GetAllAvailableSDKs() + { + string[]? resolvedPaths = null; + int rc = NativeMethods.hostfxr_get_available_sdks(exe_dir: DotnetPath.Value, result: (key, value) => resolvedPaths = value); // Errors are automatically printed to stderr. We should not continue to try to output anything if we failed. if (rc != 0) { - throw new InvalidOperationException("Failed to find all versions of .NET Core MSBuild. Call to hostfxr_get_available_sdks failed. There may be more details in stderr."); + throw new InvalidOperationException(SdkResolutionExceptionMessage(nameof(NativeMethods.hostfxr_get_available_sdks))); } - // The paths are sorted in increasing order. We want to return the newest SDKs first, however, - // so iterate over the list in reverse order. If basePath is disqualified because it was later - // than the runtime version, this ensures that RegisterDefaults will return the latest valid - // SDK instead of the earliest installed. - for (int i = paths.Length - 1; i >= 0; i--) + return resolvedPaths ?? Array.Empty(); + } + + /// + /// This native method call determines the actual location of path, including + /// resolving symbolic links. + /// + private static string? realpath(string path) + { + IntPtr ptr = NativeMethods.realpath(path, IntPtr.Zero); + string? result = Marshal.PtrToStringAuto(ptr); + NativeMethods.free(ptr); + + return result; + } + + private static string? FindDotnetPathFromEnvVariable(string environmentVariable) + { + string? dotnetPath = Environment.GetEnvironmentVariable(environmentVariable); + + return string.IsNullOrEmpty(dotnetPath) ? null : ValidatePath(dotnetPath); + } + + private static string? ValidatePath(string dotnetPath) + { + string fullPathToDotnetFromRoot = Path.Combine(dotnetPath, ExeName); + if (File.Exists(fullPathToDotnetFromRoot)) { - if (paths[i] != bestSDK) + if (!IsWindows) { - yield return paths[i]; + fullPathToDotnetFromRoot = realpath(fullPathToDotnetFromRoot) ?? fullPathToDotnetFromRoot; + return File.Exists(fullPathToDotnetFromRoot) ? Path.GetDirectoryName(fullPathToDotnetFromRoot) : null; } + + return dotnetPath; } + + return null; } } } diff --git a/src/MSBuildLocator/Utils/SemanticVersion.cs b/src/MSBuildLocator/Utils/SemanticVersion.cs new file mode 100644 index 00000000..4a3bcb11 --- /dev/null +++ b/src/MSBuildLocator/Utils/SemanticVersion.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Build.Locator +{ + internal class SemanticVersion : IComparable + { + private readonly IEnumerable _releaseLabels; + private Version _version; + private string _originalValue; + + public SemanticVersion(Version version, IEnumerable releaseLabels, string originalValue) + { + _version = version ?? throw new ArgumentNullException(nameof(version)); + + if (releaseLabels != null) + { + _releaseLabels = releaseLabels.ToArray(); + } + + _originalValue = originalValue; + } + + /// + /// Major version X (X.y.z) + /// + public int Major => _version.Major; + + /// + /// Minor version Y (x.Y.z) + /// + public int Minor => _version.Minor; + + /// + /// Patch version Z (x.y.Z) + /// + public int Patch => _version.Build; + + /// + /// A collection of pre-release labels attached to the version. + /// + public IEnumerable ReleaseLabels => _releaseLabels ?? Enumerable.Empty(); + + public string OriginalValue => _originalValue; + + /// + /// The full pre-release label for the version. + /// + public string Release => _releaseLabels != null ? String.Join(".", _releaseLabels) : String.Empty; + + /// + /// True if pre-release labels exist for the version. + /// + public bool IsPrerelease + { + get + { + if (ReleaseLabels != null) + { + var enumerator = ReleaseLabels.GetEnumerator(); + return (enumerator.MoveNext() && !String.IsNullOrEmpty(enumerator.Current)); + } + + return false; + } + } + + /// + /// Compare versions. + /// + public int CompareTo(SemanticVersion other) + { + var comparer = new VersionComparer(); + return comparer.Compare(this, other); + } + } +} diff --git a/src/MSBuildLocator/Utils/SemanticVersionParser.cs b/src/MSBuildLocator/Utils/SemanticVersionParser.cs new file mode 100644 index 00000000..9bea0025 --- /dev/null +++ b/src/MSBuildLocator/Utils/SemanticVersionParser.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NETCOREAPP + +using System; +using System.Linq; + +namespace Microsoft.Build.Locator +{ + /// + /// Converts string in its semantic version. + /// The basic parser logic is taken from https://github.com/NuGetArchive/NuGet.Versioning/releases/tag/rc-preview1. + /// + internal static class SemanticVersionParser + { + /// + /// Parse a version string + /// + /// false if the version wasn't parsed + public static bool TryParse(string value, out SemanticVersion version) + { + version = null; + + if (value != null) + { + var (versionString, releaseLabels) = ParseSections(value); + + if (Version.TryParse(versionString, out Version systemVersion)) + { + // validate the version string + string[] parts = versionString.Split('.'); + + // versions must be 3 parts + if (parts.Length != 3) + { + return false; + } + + foreach (var part in parts) + { + if (!IsValidPart(part, false)) + { + // leading zeros are not allowed + return false; + } + } + + if (releaseLabels != null && !releaseLabels.All(s => IsValidPart(s, false))) + { + return false; + } + + version = new SemanticVersion( + NormalizeVersionValue(systemVersion), + releaseLabels, + value); + + return true; + } + } + + return false; + } + + private static bool IsLetterOrDigitOrDash(char c) + { + int x = (int)c; + + // "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-" + return (x >= 48 && x <= 57) || (x >= 65 && x <= 90) || (x >= 97 && x <= 122) || x == 45; + } + + private static bool IsValidPart(string s, bool allowLeadingZeros) => IsValidPart(s.ToCharArray(), allowLeadingZeros); + + private static bool IsValidPart(char[] chars, bool allowLeadingZeros) + { + bool result = true; + + if (chars.Length == 0) + { + // empty labels are not allowed + result = false; + } + + // 0 is fine, but 00 is not. + // 0A counts as an alpha numeric string where zeros are not counted + if (!allowLeadingZeros && chars.Length > 1 && chars[0] == '0' && chars.All(c => Char.IsDigit(c))) + { + // no leading zeros in labels allowed + result = false; + } + else + { + result &= chars.All(c => IsLetterOrDigitOrDash(c)); + } + + return result; + } + + /// + /// Parse the version string into version/release + /// + private static (string Version, string[] ReleaseLabels) ParseSections(string value) + { + string versionString = null; + string[] releaseLabels = null; + + int dashPos = -1; + int plusPos = -1; + + char[] chars = value.ToCharArray(); + + bool end; + for (int i = 0; i < chars.Length; i++) + { + end = (i == chars.Length - 1); + + if (dashPos < 0) + { + if (end || chars[i] == '-' || chars[i] == '+') + { + int endPos = i + (end ? 1 : 0); + versionString = value.Substring(0, endPos); + + dashPos = i; + + if (chars[i] == '+') + { + plusPos = i; + } + } + } + else if (plusPos < 0) + { + if (end || chars[i] == '+') + { + int start = dashPos + 1; + int endPos = i + (end ? 1 : 0); + string releaseLabel = value.Substring(start, endPos - start); + + releaseLabels = releaseLabel.Split('.'); + + plusPos = i; + } + } + } + + return (versionString, releaseLabels); + } + + private static Version NormalizeVersionValue(Version version) + { + Version normalized = version; + + if (version.Build < 0 || version.Revision < 0) + { + normalized = new Version( + version.Major, + version.Minor, + Math.Max(version.Build, 0), + Math.Max(version.Revision, 0)); + } + + return normalized; + } + } +} +#endif \ No newline at end of file diff --git a/src/MSBuildLocator/Utils/VersionComparer.cs b/src/MSBuildLocator/Utils/VersionComparer.cs new file mode 100644 index 00000000..b5dabba2 --- /dev/null +++ b/src/MSBuildLocator/Utils/VersionComparer.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Build.Locator +{ + internal sealed class VersionComparer + { + /// + /// Determines if both versions are equal. + /// + public bool Equals(SemanticVersion x, SemanticVersion y) => Compare(x, y) == 0; + + /// + /// Compare versions. + /// + public int Compare(SemanticVersion x, SemanticVersion y) + { + if (Object.ReferenceEquals(x, y)) + { + return 0; + } + + if (Object.ReferenceEquals(y, null)) + { + return 1; + } + + if (Object.ReferenceEquals(x, null)) + { + return -1; + } + + if (x != null && y != null) + { + // compare version + int result = x.Major.CompareTo(y.Major); + if (result != 0) + { + return result; + } + + result = x.Minor.CompareTo(y.Minor); + if (result != 0) + { + return result; + } + + result = x.Patch.CompareTo(y.Patch); + if (result != 0) + { + return result; + } + + // compare release labels + if (x.IsPrerelease && !y.IsPrerelease) + { + return -1; + } + + if (!x.IsPrerelease && y.IsPrerelease) + { + return 1; + } + + if (x.IsPrerelease && y.IsPrerelease) + { + result = CompareReleaseLabels(x.ReleaseLabels, y.ReleaseLabels); + if (result != 0) + { + return result; + } + } + } + + return 0; + } + + /// + /// Compares sets of release labels. + /// + private static int CompareReleaseLabels(IEnumerable version1, IEnumerable version2) + { + int result = 0; + + IEnumerator a = version1.GetEnumerator(); + IEnumerator b = version2.GetEnumerator(); + + bool aExists = a.MoveNext(); + bool bExists = b.MoveNext(); + + while (aExists || bExists) + { + if (!aExists && bExists) + { + return -1; + } + + if (aExists && !bExists) + { + return 1; + } + + result = CompareRelease(a.Current, b.Current); + + if (result != 0) + { + return result; + } + + aExists = a.MoveNext(); + bExists = b.MoveNext(); + } + + return result; + } + + /// + /// Release labels are compared as numbers if they are numeric, otherwise they will be compared + /// as strings. + /// + private static int CompareRelease(string version1, string version2) + { + int result; + + // check if the identifiers are numeric + bool v1IsNumeric = Int32.TryParse(version1, out int version1Num); + bool v2IsNumeric = Int32.TryParse(version2, out int version2Num); + + // if both are numeric compare them as numbers + if (v1IsNumeric && v2IsNumeric) + { + result = version1Num.CompareTo(version2Num); + } + else if (v1IsNumeric || v2IsNumeric) + { + // numeric labels come before alpha labels + result = v1IsNumeric ? -1 : 1; + } + else + { + // Ignoring 2.0.0 case sensitive compare. Everything will be compared case insensitively as 2.0.1 specifies. + result = StringComparer.OrdinalIgnoreCase.Compare(version1, version2); + } + + return result; + } + } +}