File: DotnetCliHelper.cs
Web Access
Project: src\src\LanguageServer\Microsoft.CodeAnalysis.LanguageServer\Microsoft.CodeAnalysis.LanguageServer.csproj (Microsoft.CodeAnalysis.LanguageServer)
// 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.Composition;
using System.Diagnostics;
using System.Text;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.Extensions.Logging;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.LanguageServer;
 
[Export, Shared]
internal sealed class DotnetCliHelper
{
    internal const string DotnetRootEnvVar = "DOTNET_ROOT";
 
    private readonly ILogger _logger;
    private readonly Lazy<string> _dotnetExecutablePath;
 
    [ImportingConstructor]
    [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
    public DotnetCliHelper(ILoggerFactory loggerFactory)
    {
        _logger = loggerFactory.CreateLogger<DotnetCliHelper>();
        _dotnetExecutablePath = new Lazy<string>(() => GetDotNetPathOrDefault());
    }
 
    /// <summary>
    /// The folder the dotnet executable is in could contain multiple SDK paths.
    /// In order to figure out which one is the right one, we need to run dotnet --info
    /// from the project directory (in order to respect any global.json that might be present)
    /// which will output the correct SDK path.
    /// </summary>
    private async Task<string> GetDotnetSdkFolderFromDotnetExecutableAsync(string projectOutputDirectory, CancellationToken cancellationToken)
    {
        using var process = Run(["--info"], workingDirectory: projectOutputDirectory, shouldLocalizeOutput: false);
 
        string? dotnetSdkFolderPath = null;
        process.OutputDataReceived += (_, e) =>
        {
            if (!string.IsNullOrEmpty(e.Data))
            {
                var line = e.Data.Trim();
                if (line.StartsWith("Base Path", StringComparison.OrdinalIgnoreCase))
                {
                    dotnetSdkFolderPath = e.Data.Split(':', count: 2)[1].Trim();
                }
            }
        };
 
        var errorOutput = new StringBuilder();
        var stdOutput = new StringBuilder();
        process.ErrorDataReceived += (_, e) => errorOutput.AppendLine(e.Data);
        process.OutputDataReceived += (_, e) => stdOutput.AppendLine(e.Data);
 
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();
        await process.WaitForExitAsync(cancellationToken);
        _logger.LogDebug(stdOutput.ToString());
        if (process.ExitCode != 0 || dotnetSdkFolderPath == null)
        {
            _logger.LogError(errorOutput.ToString());
            throw new InvalidOperationException("Failed to get dotnet SDK folder from dotnet --info");
        }
 
        return dotnetSdkFolderPath;
    }
 
    public Process Run(string[] arguments, string? workingDirectory, bool shouldLocalizeOutput)
    {
        _logger.LogDebug($"Running dotnet CLI command at {_dotnetExecutablePath.Value} in directory {workingDirectory} with arguments {arguments}");
 
        var startInfo = new ProcessStartInfo(_dotnetExecutablePath.Value)
        {
            CreateNoWindow = true,
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
        };
 
        startInfo.ArgumentList.AddRange(arguments);
 
        if (workingDirectory != null)
        {
            startInfo.WorkingDirectory = workingDirectory;
        }
 
        // If we're relying on the user's configured CLI to run this we need to remove our custom DOTNET_ROOT that we set
        // Wto start the server; otherwise we won't find the users installed sdks.
        startInfo.Environment.Remove(DotnetRootEnvVar);
 
        if (!shouldLocalizeOutput)
        {
            // The caller requested that we not localize the output of the command (typically be cause it needs to be parsed)
            // Set the appropriate environment variable for the process so that we don't get localized output.
            startInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en-US";
        }
 
        // Since we depend on MSBuild APIs, the following environment variables get set to the version of dotnet that runs this server.
        // However want to use the user specified dotnet version to run the tests, so we need to unset these.
        startInfo.Environment.Remove("MSBUILD_EXE_PATH");
        startInfo.Environment.Remove("MSBuildExtensionsPath");
 
        var process = Process.Start(startInfo);
        Contract.ThrowIfNull(process, $"Unable to start dotnet CLI at {_dotnetExecutablePath.Value} with arguments {arguments} in directory {workingDirectory}");
        return process;
    }
 
    public async Task<string> GetVsTestConsolePathAsync(string projectOutputDirectory, CancellationToken cancellationToken)
    {
        var dotnetSdkFolder = await GetDotnetSdkFolderFromDotnetExecutableAsync(projectOutputDirectory, cancellationToken);
        var vstestConsole = Path.Combine(dotnetSdkFolder, "vstest.console.dll");
        Contract.ThrowIfFalse(File.Exists(vstestConsole), $"VSTestConsole was not found at {vstestConsole}");
        _logger.LogDebug($"Using vstest console at {vstestConsole}");
        return vstestConsole;
    }
 
    /// <summary>
    /// Finds the dotnet executable path from the PATH environment variable.
    /// Based on https://github.com/dotnet/msbuild/blob/main/src/Utilities/ToolTask.cs#L1259
    /// We also do not include DOTNET_ROOT here, see https://github.com/dotnet/runtime/issues/88754
    /// </summary>
    /// <returns></returns>
    internal string GetDotNetPathOrDefault()
    {
        var (fileName, sep) = PlatformInformation.IsWindows
            ? ("dotnet.exe", ';')
            : ("dotnet", ':');
 
        var path = Environment.GetEnvironmentVariable("PATH") ?? "";
        foreach (var item in path.Split(sep, StringSplitOptions.RemoveEmptyEntries))
        {
            try
            {
                var filePath = Path.Combine(item, fileName);
                if (File.Exists(filePath))
                {
                    _logger.LogInformation("Using dotnet executable configured on the PATH");
                    return filePath;
                }
            }
            catch
            {
                // If we can't read a directory for any reason just skip it
            }
        }
 
        _logger.LogInformation("Could not find dotnet executable from PATH");
        return fileName;
    }
}