From da9b3924990415ca91068fae2daf7c229686fa01 Mon Sep 17 00:00:00 2001 From: Jason Malinowski Date: Mon, 2 Oct 2023 17:53:54 -0700 Subject: [PATCH] Add support for using Mono to load .NET Framework projects We'll use this on Mac and Linux; we already had equivalent support on Windows. MonoMSBuildDiscovery.cs is largely a copy of the code from these paths, with some tweaks to remove Omnisharp scenarios that don't apply for us. - https://github.com/OmniSharp/omnisharp-roslyn/blob/dde8119c40f4e3920eb5ea894cbca047033bd9aa/src/OmniSharp.Host/MSBuild/Discovery/Providers/MonoInstanceProvider.cs - https://github.com/OmniSharp/omnisharp-roslyn/blob/dde8119c40f4e3920eb5ea894cbca047033bd9aa/src/OmniSharp.Host/MSBuild/Discovery/MSBuildInstanceProvider.cs --- .../HostWorkspace/BuildHostProcessManager.cs | 65 ++++--- .../LanguageServerProjectSystem.cs | 7 +- ...crosoft.CodeAnalysis.LanguageServer.csproj | 11 +- .../Core/MSBuild.BuildHost/BuildHost.cs | 71 ++++++-- .../MSBuild.BuildHost/MonoMSBuildDiscovery.cs | 171 ++++++++++++++++++ 5 files changed, 275 insertions(+), 50 deletions(-) create mode 100644 src/Workspaces/Core/MSBuild.BuildHost/MonoMSBuildDiscovery.cs diff --git a/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/BuildHostProcessManager.cs b/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/BuildHostProcessManager.cs index 25ce59ab9279a..ae5ef3eeb0dab 100644 --- a/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/BuildHostProcessManager.cs +++ b/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/BuildHostProcessManager.cs @@ -35,10 +35,17 @@ public async Task GetBuildHostAsync(string projectFilePath, Cancella _logger?.LogTrace($"Choosing a build host of type {neededBuildHostKind} for {projectFilePath}."); + if (neededBuildHostKind == BuildHostProcessKind.Mono && MonoMSBuildDiscovery.GetMonoMSBuildDirectory() == null) + { + _logger?.LogWarning($"An installation of Mono could not be found; {projectFilePath} will be loaded with the .NET Core SDK and may encounter errors."); + neededBuildHostKind = BuildHostProcessKind.NetCore; + } + var buildHost = await GetBuildHostAsync(neededBuildHostKind, cancellationToken).ConfigureAwait(false); // If this is a .NET Framework build host, we may not have have build tools installed and thus can't actually use it to build. - // Check if this is the case. + // Check if this is the case. Unlike the mono case, we have to actually ask the other process since MSBuildLocator only allows + // us to discover VS instances in .NET Framework hosts right now. if (neededBuildHostKind == BuildHostProcessKind.NetFramework) { if (!await buildHost.HasUsableMSBuildAsync(projectFilePath, cancellationToken)) @@ -58,13 +65,17 @@ public async Task GetBuildHostAsync(BuildHostProcessKind buildHostKi { if (!_processes.TryGetValue(buildHostKind, out var buildHostProcess)) { - var process = buildHostKind switch + var processStartInfo = buildHostKind switch { - BuildHostProcessKind.NetCore => LaunchDotNetCoreBuildHost(), - BuildHostProcessKind.NetFramework => LaunchDotNetFrameworkBuildHost(), + BuildHostProcessKind.NetCore => CreateDotNetCoreBuildHostStartInfo(), + BuildHostProcessKind.NetFramework => CreateDotNetFrameworkBuildHostStartInfo(), + BuildHostProcessKind.Mono => CreateMonoBuildHostStartInfo(), _ => throw ExceptionUtilities.UnexpectedValue(buildHostKind) }; + var process = Process.Start(processStartInfo); + Contract.ThrowIfNull(process, "Process.Start failed to launch a process."); + buildHostProcess = new BuildHostProcess(process, _loggerFactory); buildHostProcess.Disconnected += BuildHostProcess_Disconnected; _processes.Add(buildHostKind, buildHostProcess); @@ -108,7 +119,7 @@ public async ValueTask DisposeAsync() await process.DisposeAsync(); } - private Process LaunchDotNetCoreBuildHost() + private ProcessStartInfo CreateDotNetCoreBuildHostStartInfo() { var processStartInfo = new ProcessStartInfo() { @@ -125,16 +136,12 @@ private Process LaunchDotNetCoreBuildHost() AppendBuildHostCommandLineArgumentsConfigureProcess(processStartInfo); - var process = Process.Start(processStartInfo); - Contract.ThrowIfNull(process, "Process.Start failed to launch a process."); - return process; + return processStartInfo; } - private Process LaunchDotNetFrameworkBuildHost() + private ProcessStartInfo CreateDotNetFrameworkBuildHostStartInfo() { - var netFrameworkBuildHost = Path.Combine(Path.GetDirectoryName(typeof(BuildHostProcessManager).Assembly.Location)!, "BuildHost-net472", "Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe"); - Contract.ThrowIfFalse(File.Exists(netFrameworkBuildHost), $"Unable to locate the .NET Framework build host at {netFrameworkBuildHost}"); - + var netFrameworkBuildHost = GetPathToDotNetFrameworkBuildHost(); var processStartInfo = new ProcessStartInfo() { FileName = netFrameworkBuildHost, @@ -142,9 +149,28 @@ private Process LaunchDotNetFrameworkBuildHost() AppendBuildHostCommandLineArgumentsConfigureProcess(processStartInfo); - var process = Process.Start(processStartInfo); - Contract.ThrowIfNull(process, "Process.Start failed to launch a process."); - return process; + return processStartInfo; + } + + private ProcessStartInfo CreateMonoBuildHostStartInfo() + { + var processStartInfo = new ProcessStartInfo + { + FileName = "mono" + }; + + processStartInfo.ArgumentList.Add(GetPathToDotNetFrameworkBuildHost()); + + AppendBuildHostCommandLineArgumentsConfigureProcess(processStartInfo); + + return processStartInfo; + } + + private static string GetPathToDotNetFrameworkBuildHost() + { + var netFrameworkBuildHost = Path.Combine(Path.GetDirectoryName(typeof(BuildHostProcessManager).Assembly.Location)!, "BuildHost-net472", "Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe"); + Contract.ThrowIfFalse(File.Exists(netFrameworkBuildHost), $"Unable to locate the .NET Framework build host at {netFrameworkBuildHost}"); + return netFrameworkBuildHost; } private void AppendBuildHostCommandLineArgumentsConfigureProcess(ProcessStartInfo processStartInfo) @@ -173,10 +199,6 @@ private void AppendBuildHostCommandLineArgumentsConfigureProcess(ProcessStartInf private static BuildHostProcessKind GetKindForProject(string projectFilePath) { - // At the moment we don't have mono support here, so if we're not on Windows, we'll always force to .NET Core. - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return BuildHostProcessKind.NetCore; - // This implements the algorithm as stated in https://github.com/dotnet/project-system/blob/9a761848e0f330a45e349685a266fea00ac3d9c5/docs/opening-with-new-project-system.md; // we'll load the XML of the project directly, and inspect for certain elements. XDocument document; @@ -208,13 +230,14 @@ private static BuildHostProcessKind GetKindForProject(string projectFilePath) return BuildHostProcessKind.NetCore; // Nothing that indicates it's an SDK-style project, so use our .NET framework host - return BuildHostProcessKind.NetFramework; + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? BuildHostProcessKind.NetFramework : BuildHostProcessKind.Mono; } public enum BuildHostProcessKind { NetCore, - NetFramework + NetFramework, + Mono } private sealed class BuildHostProcess : IAsyncDisposable diff --git a/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs b/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs index 82a1090a5474f..337f58c113d30 100644 --- a/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs +++ b/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs @@ -6,6 +6,7 @@ using System.Collections.Immutable; using System.Composition; using System.Diagnostics; +using System.Runtime.InteropServices; using Microsoft.CodeAnalysis.Collections; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Host.Mef; @@ -18,6 +19,7 @@ using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.Composition; using Roslyn.Utilities; +using static Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.BuildHostProcessManager; using LSP = Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; @@ -96,7 +98,10 @@ public async Task OpenSolutionAsync(string solutionFilePath) // If we don't have a .NET Core SDK on this machine at all, try .NET Framework if (!await buildHost.HasUsableMSBuildAsync(solutionFilePath, CancellationToken.None)) - buildHost = await buildHostProcessManager.GetBuildHostAsync(BuildHostProcessManager.BuildHostProcessKind.NetFramework, CancellationToken.None); + { + var kind = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? BuildHostProcessKind.NetFramework : BuildHostProcessKind.Mono; + buildHost = await buildHostProcessManager.GetBuildHostAsync(kind, CancellationToken.None); + } foreach (var project in await buildHost.GetProjectsInSolutionAsync(solutionFilePath, CancellationToken.None)) { diff --git a/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Microsoft.CodeAnalysis.LanguageServer.csproj b/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Microsoft.CodeAnalysis.LanguageServer.csproj index 5c28c18ada715..47d2653e18600 100644 --- a/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Microsoft.CodeAnalysis.LanguageServer.csproj +++ b/src/Features/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Microsoft.CodeAnalysis.LanguageServer.csproj @@ -130,20 +130,13 @@ - + - + diff --git a/src/Workspaces/Core/MSBuild.BuildHost/BuildHost.cs b/src/Workspaces/Core/MSBuild.BuildHost/BuildHost.cs index 652c07e88c768..41ef996bef171 100644 --- a/src/Workspaces/Core/MSBuild.BuildHost/BuildHost.cs +++ b/src/Workspaces/Core/MSBuild.BuildHost/BuildHost.cs @@ -43,36 +43,61 @@ private bool TryEnsureMSBuildLoaded(string projectOrSolutionFilePath) return true; } - VisualStudioInstance? instance; + if (!PlatformInformation.IsRunningOnMono) + { + + VisualStudioInstance? instance; #if NETFRAMEWORK - // In this case, we're just going to pick the highest VS install on the machine, in case the projects are using some newer - // MSBuild features. Since we don't have something like a global.json we can't really know what the minimum version is. + // In this case, we're just going to pick the highest VS install on the machine, in case the projects are using some newer + // MSBuild features. Since we don't have something like a global.json we can't really know what the minimum version is. - // TODO: we should also check that the managed tools are actually installed - instance = MSBuildLocator.QueryVisualStudioInstances().OrderByDescending(vs => vs.Version).FirstOrDefault(); + // TODO: we should also check that the managed tools are actually installed + instance = MSBuildLocator.QueryVisualStudioInstances().OrderByDescending(vs => vs.Version).FirstOrDefault(); #else - // Locate the right SDK for this particular project; MSBuildLocator ensures in this case the first one is the preferred one. - // TODO: we should pick the appropriate instance back in the main process and just use the one chosen here. - var options = new VisualStudioInstanceQueryOptions { DiscoveryTypes = DiscoveryType.DotNetSdk, WorkingDirectory = Path.GetDirectoryName(projectOrSolutionFilePath) }; - instance = MSBuildLocator.QueryVisualStudioInstances(options).FirstOrDefault(); + // Locate the right SDK for this particular project; MSBuildLocator ensures in this case the first one is the preferred one. + // TODO: we should pick the appropriate instance back in the main process and just use the one chosen here. + var options = new VisualStudioInstanceQueryOptions { DiscoveryTypes = DiscoveryType.DotNetSdk, WorkingDirectory = Path.GetDirectoryName(projectOrSolutionFilePath) }; + instance = MSBuildLocator.QueryVisualStudioInstances(options).FirstOrDefault(); #endif - if (instance != null) - { - MSBuildLocator.RegisterInstance(instance); - _logger.LogInformation($"Registered MSBuild instance at {instance.MSBuildPath}"); - return true; + if (instance != null) + { + MSBuildLocator.RegisterInstance(instance); + _logger.LogInformation($"Registered MSBuild instance at {instance.MSBuildPath}"); + } + else + { + _logger.LogCritical("No compatible MSBuild instance could be found."); + } } else { - _logger.LogCritical("No compatible MSBuild instance could be found."); - return false; +#if NETFRAMEWORK + + // We're running on Mono, but not all Mono installations have a usable MSBuild installation, so let's see if we have one that we can use. + var monoMSBuildDirectory = MonoMSBuildDiscovery.GetMonoMSBuildDirectory(); + + if (monoMSBuildDirectory != null) + { + MSBuildLocator.RegisterMSBuildPath(monoMSBuildDirectory); + _logger.LogInformation($"Registered MSBuild instance at {monoMSBuildDirectory}"); + } + else + { + _logger.LogCritical("No Mono MSBuild installation could be found; see https://www.mono-project.com/ for installation instructions."); + } + +#else + _logger.LogCritical("Trying to run the .NET Core BuildHost on Mono is unsupported."); +#endif } + + return MSBuildLocator.IsRegistered; } } @@ -117,9 +142,17 @@ private void EnsureMSBuildLoaded(string projectFilePath) [MethodImpl(MethodImplOptions.NoInlining)] // Do not inline this, since this uses MSBuild types which are being loaded by the caller private static ImmutableArray<(string ProjectPath, string ProjectGuid)> GetProjectsInSolution(string solutionFilePath) { - return SolutionFile.Parse(solutionFilePath).ProjectsInOrder - .Where(static p => p.ProjectType != SolutionProjectType.SolutionFolder) - .SelectAsArray(static p => (p.AbsolutePath, p.ProjectGuid)); + var builder = ImmutableArray.CreateBuilder<(string ProjectPath, string ProjectGuid)>(); + + foreach (var project in SolutionFile.Parse(solutionFilePath).ProjectsInOrder) + { + if (project.ProjectType != SolutionProjectType.SolutionFolder) + { + builder.Add((project.AbsolutePath, project.ProjectGuid)); + } + } + + return builder.ToImmutable(); } public Task IsProjectFileSupportedAsync(string projectFilePath, CancellationToken cancellationToken) diff --git a/src/Workspaces/Core/MSBuild.BuildHost/MonoMSBuildDiscovery.cs b/src/Workspaces/Core/MSBuild.BuildHost/MonoMSBuildDiscovery.cs new file mode 100644 index 0000000000000..f072b9e9a071f --- /dev/null +++ b/src/Workspaces/Core/MSBuild.BuildHost/MonoMSBuildDiscovery.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost; + +internal static class MonoMSBuildDiscovery +{ + private static IEnumerable? s_searchPaths; + private static string? s_monoRuntimeExecutablePath; + private static string? s_monoLibDirPath; + private static string? s_monoMSBuildDirectory; + + private static IEnumerable GetSearchPaths() + { + if (s_searchPaths == null) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (path == null) + { + return Array.Empty(); + } + + s_searchPaths = path + .Split(Path.PathSeparator) + .Select(p => p.Trim('"')); + } + + return s_searchPaths; + } + + // http://man7.org/linux/man-pages/man3/realpath.3.html + // CharSet.Ansi is UTF8 on Unix + [DllImport("libc", EntryPoint = "realpath", CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr Unix_realpath(string path, IntPtr buffer); + + // http://man7.org/linux/man-pages/man3/free.3.html + [DllImport("libc", EntryPoint = "free", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern void Unix_free(IntPtr ptr); + + /// + /// Returns the canonicalized absolute path from a given path, expanding symbolic links and resolving + /// references to /./, /../ and extra '/' path characters. + /// + private static string? RealPath(string path) + { + if (PlatformInformation.IsWindows) + { + throw new PlatformNotSupportedException($"{nameof(RealPath)} can only be called on Unix."); + } + + var ptr = Unix_realpath(path, IntPtr.Zero); + var result = Marshal.PtrToStringAnsi(ptr); // uses UTF8 on Unix + Unix_free(ptr); + + return result; + } + + /// + /// Returns the fully qualified path to the mono executable. + /// + private static string? GetMonoRuntimeExecutablePath() + { + if (PlatformInformation.IsWindows) + { + return null; + } + + if (s_monoRuntimeExecutablePath == null) + { + var monoPath = GetSearchPaths() + .Select(p => Path.Combine(p, "mono")) + .FirstOrDefault(File.Exists); + + if (monoPath == null) + { + return null; + } + + s_monoRuntimeExecutablePath = RealPath(monoPath); + } + + return s_monoRuntimeExecutablePath; + } + + /// + /// Returns the path to the mono lib directory, usually /usr/bin/mono. + /// + private static string? GetMonoLibDirPath() + { + if (PlatformInformation.IsWindows) + { + return null; + } + + const string DefaultMonoLibPath = "/usr/lib/mono"; + if (Directory.Exists(DefaultMonoLibPath)) + { + return DefaultMonoLibPath; + } + + // The normal Unix path doesn't exist, so we'll fallback to finding Mono using the + // runtime location. This is the likely situation on macOS. + + if (s_monoLibDirPath == null) + { + var monoRuntimePath = GetMonoRuntimeExecutablePath(); + if (monoRuntimePath == null) + { + return null; + } + + var monoDirPath = Path.GetDirectoryName(monoRuntimePath)!; + + var monoLibDirPath = Path.Combine(monoDirPath, "..", "lib", "mono"); + monoLibDirPath = Path.GetFullPath(monoLibDirPath); + + s_monoLibDirPath = Directory.Exists(monoLibDirPath) + ? monoLibDirPath + : null; + } + + return s_monoLibDirPath; + } + + /// + /// Returns the path to MSBuild, the actual directory containing MSBuild.dll and friends. Usually should end in Current/bin. + /// + public static string? GetMonoMSBuildDirectory() + { + if (PlatformInformation.IsWindows) + { + return null; + } + + if (s_monoMSBuildDirectory == null) + { + var monoLibDirPath = GetMonoLibDirPath(); + if (monoLibDirPath == null) + return null; + + var monoMSBuildDirPath = Path.Combine(monoLibDirPath, "msbuild"); + var monoMSBuildDir = new DirectoryInfo(Path.GetFullPath(monoMSBuildDirPath)); + + if (!monoMSBuildDir.Exists) + return null; + + // Inside this is either a Current directory or a 15.0 directory, so find it; the previous code at + // https://github.com/OmniSharp/omnisharp-roslyn/blob/dde8119c40f4e3920eb5ea894cbca047033bd9aa/src/OmniSharp.Host/MSBuild/Discovery/MSBuildInstanceProvider.cs#L48-L58 + // ensured we had a correctly normalized path in case the underlying file system might have been case insensitive. + var versionDirectory = + monoMSBuildDir.EnumerateDirectories().SingleOrDefault(d => d.Name == "Current") ?? + monoMSBuildDir.EnumerateDirectories().SingleOrDefault(d => d.Name == "15.0"); + + if (versionDirectory == null) + return null; + + // Fetch the bin directory underneath, continuing to be case insensitive + s_monoMSBuildDirectory = versionDirectory.EnumerateDirectories().SingleOrDefault(d => string.Equals(d.Name, "bin", StringComparison.OrdinalIgnoreCase))?.FullName; + } + + return s_monoMSBuildDirectory; + } +}