using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; namespace WoofWare.DotnetRuntimeLocator; /// /// The result of a call to `DotnetRuntime.Select`. /// This is `type DotnetRuntimeSelection = | Framework of DotnetEnvironmentFrameworkInfo | Sdk of /// DotnetEnvironmentSdkInfo | Absent`. /// internal class DotnetRuntimeSelection { private readonly int _discriminator; private readonly DotnetEnvironmentFrameworkInfo? _framework; private readonly DotnetEnvironmentSdkInfo? _sdk; /// /// The constructor which means "We found the right runtime, and it's from this framework". /// /// For example, public DotnetRuntimeSelection(DotnetEnvironmentFrameworkInfo framework) { _discriminator = 1; _framework = framework; } /// /// The constructor which means "We found the right runtime, and it's from this SDK". /// /// For example, public DotnetRuntimeSelection(DotnetEnvironmentSdkInfo sdk) { _discriminator = 2; _sdk = sdk; } /// /// The constructor which means "We were unable to find an appropriate runtime". /// public DotnetRuntimeSelection() { _discriminator = 3; } /// /// Exhaustive match on this discriminated union. /// /// If `this` is a `Framework`, call this continuation with its value. /// If `this` is a `Sdk`, call this continuation with its value. /// If `this` represents the absence of a result, call this continuation. /// The result of the continuation which was called. public TRet Visit(Func withFramework, Func withSdk, Func withNone) { return _discriminator switch { 1 => withFramework.Invoke(_framework!), 2 => withSdk.Invoke(_sdk!), 3 => withNone.Invoke(), _ => throw new InvalidOperationException($"unrecognised union discriminator %i{_discriminator}") }; } } /// /// Module to hold methods for automatically identifying a .NET runtime. /// public static class DotnetRuntime { /// For each requested runtime in the RuntimeOptions, the resolved place in which to find that runtime. private static IReadOnlyDictionary SelectRuntime(RuntimeOptions options, DotnetEnvironmentInfo env) { var rollForwardEnvVar = Environment.GetEnvironmentVariable("DOTNET_ROLL_FORWARD"); RollForward rollForward; if (rollForwardEnvVar == null) { rollForward = options.RollForward ?? RollForward.Minor; } else { if (!Enum.TryParse(rollForwardEnvVar, out rollForward)) throw new ArgumentException( $"Unable to parse the value of environment variable DOTNET_ROLL_FORWARD, which was: {rollForwardEnvVar}"); } IReadOnlyDictionary desiredVersions; if (options.IncludedFrameworks == null) { if (options.Framework == null) { if (options.Frameworks == null) throw new InvalidDataException( "Expected runtimeconfig.json file to have either a framework or frameworks entry, but it had neither"); desiredVersions = options.Frameworks.Select(x => (x.Name, new Version(x.Version))).GroupBy(x => x.Name) .Select(data => { var versions = (IReadOnlyList)data.Select(datum => datum.Item2).ToList(); if (versions.Count != 1) { var description = string.Join(", ", versions.Select(x => x.ToString())); throw new InvalidDataException( $"Unexpectedly had not-exactly-one version desired for framework {data.Key}: {description}"); } return (data.Key, versions[0]); }) .ToDictionary(); } else { var result = new Dictionary { { options.Framework.Name, new Version(options.Framework.Version) } }; desiredVersions = result; } } else { desiredVersions = options.IncludedFrameworks.Select(x => (x.Name, new Version(x.Version))) .GroupBy(x => x.Name) .Select(data => { var versions = (IReadOnlyList)data.Select(datum => datum.Item2).ToList(); if (versions.Count != 1) { var description = string.Join(", ", versions.Select(x => x.ToString())); throw new InvalidDataException( $"Unexpectedly had not-exactly-one version desired for framework {data.Key}: {description}"); } return (data.Key, versions[0]); }) .ToDictionary(); } IReadOnlyDictionary> availableRuntimes = env .Frameworks.SelectMany(availableFramework => { var availableVersion = new Version(availableFramework.Version); if (!desiredVersions.TryGetValue(availableFramework.Name, out var desiredVersion)) { // we don't desire this framework at any version; skip it return []; } if (availableVersion < desiredVersion) { // It's never desired to roll *backward*. return []; } return new List<(string, DotnetEnvironmentFrameworkInfo)> { (availableFramework.Name, availableFramework) }; }).GroupBy(x => x.Item1) .Select(group => { var grouping = group.Select(x => new RuntimeOnDisk(x.Item2, new Version(x.Item2.Version))).ToList(); return (group.Key, (IReadOnlyList)grouping); }) .ToDictionary(); switch (rollForward) { case RollForward.Minor: { return desiredVersions.Select(desired => { if (!availableRuntimes.TryGetValue(desired.Key, out var available)) { return (desired.Key, new DotnetRuntimeSelection()); } if (ReferenceEquals(available, null)) { throw new NullReferenceException("logic error: contents of non-nullable dict can't be null"); } // If there's a correct major and minor version, take the latest patch. var correctMajorAndMinorVersion = available.Where(data => data.InstalledVersion.Major == desired.Value.Major && data.InstalledVersion.Minor == desired.Value.Minor).ToList(); if (correctMajorAndMinorVersion.Count > 0) { return (desired.Key, new DotnetRuntimeSelection(correctMajorAndMinorVersion.MaxBy(v => v.InstalledVersion)!.Installed)); } // Otherwise roll forward to lowest higher minor version var candidate = available.Where(data => data.InstalledVersion.Major == desired.Value.Major) .MinBy(v => (v.InstalledVersion.Minor, -v.InstalledVersion.Build)); return (desired.Key, candidate == null ? new DotnetRuntimeSelection() : new DotnetRuntimeSelection(candidate.Installed)); }).ToDictionary(); } case RollForward.Major: { throw new NotImplementedException(); } case RollForward.LatestPatch: { return desiredVersions.Select(desired => { var matches = availableRuntimes[desired.Key] .Where(data => data.InstalledVersion.Minor == desired.Value.Minor && data.InstalledVersion.Major == desired.Value.Major).MaxBy(data => data.InstalledVersion); return matches == null ? (desired.Key, new DotnetRuntimeSelection()) : (desired.Key, new DotnetRuntimeSelection(matches.Installed)); }).ToDictionary(); } case RollForward.LatestMinor: { return desiredVersions.Select(desired => { var matches = availableRuntimes[desired.Key] .Where(data => data.InstalledVersion.Major == desired.Value.Major).MaxBy(data => data.InstalledVersion); return matches == null ? (desired.Key, new DotnetRuntimeSelection()) : (desired.Key, new DotnetRuntimeSelection(matches.Installed)); }).ToDictionary(); } case RollForward.LatestMajor: { return desiredVersions.Select(desired => { var match = availableRuntimes[desired.Key].MaxBy(data => data.InstalledVersion); return match == null ? (desired.Key, new DotnetRuntimeSelection()) : (desired.Key, new DotnetRuntimeSelection(match.Installed)); }).ToDictionary(); } case RollForward.Disable: { return desiredVersions.Select(desired => { var exactMatch = availableRuntimes[desired.Key] .FirstOrDefault(available => available.InstalledVersion == desired.Value); if (exactMatch != null) { return (desired.Key, new DotnetRuntimeSelection(exactMatch.Installed)); } else { return (desired.Key, new DotnetRuntimeSelection()); } } ).ToDictionary(); } default: { throw new ArgumentOutOfRangeException(); } } } private static JsonSerializerOptions _options = new() {PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() }}; /// /// Parse a runtimeconfig.json file. /// /// Contents of the runtimeconfig.json file to parse. /// I think this can't happen, but the docs suggest that deserialization might return null. public static RuntimeConfig? DeserializeRuntimeConfig(string contents) { return JsonSerializer.Deserialize(contents, _options); } /// /// Given a .NET executable DLL, identify the most appropriate .NET runtime to run it. /// This is pretty half-baked at the moment; test this yourself to make sure it does what you want it to! /// /// Path to an OutputType=Exe .dll file. /// /// Path to the `dotnet` binary which you would use e.g. in `dotnet exec` to run the DLL specified by /// `dllPath`. /// /// /// An ordered collection of folder paths. When resolving any particular DLL during the execution of the input /// DLL, search these folders; if a DLL name appears in multiple of these folders, the earliest is correct for that /// DLL. /// public static IReadOnlyList SelectForDll(string dllPath, string? dotnet = null) { if (!dllPath.EndsWith(".dll", StringComparison.Ordinal)) throw new ArgumentException( $"SelectForDll requires the input DLL to have the extension '.dll'; provided: {dllPath}"); var dll = new FileInfo(dllPath); var dllParentDir = dll.Directory ?? throw new ArgumentException($"dll path {dllPath} had no parent"); var name = dll.Name.Substring(0, dll.Name.Length - ".dll".Length); var configFilePath = Path.Combine(dllParentDir.FullName, $"{name}.runtimeconfig.json"); // It appears to be undocumented why this returns a nullable, and the Rider decompiler doesn't suggest there are // any code paths where it can return null? var contents = File.ReadAllText(configFilePath); var runtimeConfig = DeserializeRuntimeConfig(contents) ?? throw new NullReferenceException($"Failed to parse contents of file {configFilePath} as a runtime config"); var availableRuntimes = dotnet == null ? DotnetEnvironmentInfo.Get() : DotnetEnvironmentInfo.GetSpecific(new FileInfo(dotnet)); var runtimes = SelectRuntime(runtimeConfig.RuntimeOptions, availableRuntimes); return runtimes.SelectMany(runtime => runtime.Value.Visit(framework => new[] { $"{framework.Path}/{framework.Version}" }, sdk => [sdk.Path], () => [] )).Prepend(dllParentDir.FullName).ToList(); } private record RuntimeOnDisk( DotnetEnvironmentFrameworkInfo Installed, Version InstalledVersion); }