File: LanguageServer\Handler\Restore\RestoreHandler.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.Collections.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.Threading;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler;
 
/// <summary>
/// Given an input project (or none), runs restore on the project and streams the output
/// back to the client to display.
/// </summary>
[ExportCSharpVisualBasicStatelessLspService(typeof(RestoreHandler)), Shared]
[Method(MethodName)]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed class RestoreHandler(DotnetCliHelper dotnetCliHelper, ILoggerFactory loggerFactory) : ILspServiceRequestHandler<RestoreParams, RestoreResult>
{
    internal const string MethodName = "workspace/_roslyn_restore";
 
    public bool MutatesSolutionState => false;
 
    public bool RequiresLSPSolution => true;
 
    private readonly ILogger<RestoreHandler> _logger = loggerFactory.CreateLogger<RestoreHandler>();
 
    public async Task<RestoreResult> HandleRequestAsync(RestoreParams request, RequestContext context, CancellationToken cancellationToken)
    {
        Contract.ThrowIfNull(context.Solution);
 
        var restorePaths = GetRestorePaths(request, context.Solution, context);
        if (restorePaths.IsEmpty)
        {
            _logger.LogDebug($"Restore was requested but no paths were provided.");
            return new RestoreResult(true);
        }
 
        var workDoneProgressManager = context.GetRequiredService<WorkDoneProgressManager>();
        _logger.LogDebug($"Running restore on {restorePaths.Length} paths, starting with '{restorePaths.First()}'.");
 
        // We let cancellation here bubble up to the client as this is a client initiated operation.
        var didSucceed = await RestoreAsync(restorePaths, workDoneProgressManager, dotnetCliHelper, _logger, enableProgressReporting: true, cancellationToken);
 
        if (didSucceed)
        {
            _logger.LogDebug($"Restore completed successfully.");
        }
        else
        {
            _logger.LogError($"Restore completed with errors.");
        }
 
        return new RestoreResult(didSucceed);
    }
 
    /// <returns>True if all restore invocations exited with code 0. Otherwise, false.</returns>
    public static async Task<bool> RestoreAsync(
        ImmutableArray<string> pathsToRestore,
        WorkDoneProgressManager workDoneProgressManager,
        DotnetCliHelper dotnetCliHelper,
        ILogger logger,
        bool enableProgressReporting,
        CancellationToken cancellationToken)
    {
        using var progress = await workDoneProgressManager.CreateWorkDoneProgressAsync(reportProgressToClient: enableProgressReporting, cancellationToken);
        // Ensure we're observing cancellation token from the work done progress (to allow client cancellation).
        cancellationToken = progress.CancellationToken;
        return await RestoreCoreAsync(pathsToRestore, progress, dotnetCliHelper, logger, cancellationToken);
 
    }
 
    private static async Task<bool> RestoreCoreAsync(
        ImmutableArray<string> pathsToRestore,
        IWorkDoneProgressReporter progress,
        DotnetCliHelper dotnetCliHelper,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        // Report the start of the work done progress to the client.
        progress.Report(new WorkDoneProgressBegin()
        {
            Title = LanguageServerResources.Restore,
            // Adds a cancel button to the client side progress UI.
            // Cancellation here is fine, it just means the restore will be incomplete (same as a cntrl+C for a CLI restore).
            Cancellable = true,
            Message = LanguageServerResources.Restore_started,
            Percentage = 0,
        });
 
        var success = true;
        foreach (var path in pathsToRestore)
        {
            var arguments = new string[] { "restore", path };
            var workingDirectory = Path.GetDirectoryName(path);
            var stageName = string.Format(LanguageServerResources.Restoring_0, Path.GetFileName(path));
            ReportProgress(progress, stageName, string.Format(LanguageServerResources.Running_dotnet_restore_on_0, path));
 
            var process = dotnetCliHelper.Run(arguments, workingDirectory, shouldLocalizeOutput: true);
 
            cancellationToken.Register(() =>
            {
                process?.Kill();
            });
 
            process.OutputDataReceived += (sender, args) => ReportProgressInEvent(progress, stageName, args.Data);
            process.ErrorDataReceived += (sender, args) => ReportProgressInEvent(progress, stageName, args.Data);
            process.BeginOutputReadLine();
            process.BeginErrorReadLine();
 
            await process.WaitForExitAsync(cancellationToken);
 
            if (process.ExitCode != 0)
            {
                ReportProgress(progress, stageName, string.Format(LanguageServerResources.Failed_to_run_restore_on_0, path));
                success = false;
            }
        }
 
        // Report work done progress completion
        progress.Report(
            new WorkDoneProgressEnd()
            {
                Message = LanguageServerResources.Restore_complete
            });
 
        logger.LogInformation(LanguageServerResources.Restore_complete);
        return success;
 
        void ReportProgressInEvent(IWorkDoneProgressReporter progress, string stage, string? restoreOutput)
        {
            if (restoreOutput == null)
                return;
 
            try
            {
                ReportProgress(progress, stage, restoreOutput);
            }
            catch (Exception)
            {
                // Catch everything to ensure the exception doesn't escape the event handler.
                // Errors already reported via ReportNonFatalErrorUnlessCancelledAsync.
            }
        }
 
        void ReportProgress(IWorkDoneProgressReporter progress, string stage, string message)
        {
            logger.LogInformation("{stage}: {Output}", stage, message);
            var report = new WorkDoneProgressReport()
            {
                Message = stage,
                Percentage = null,
                Cancellable = true,
            };
 
            progress.Report(report);
        }
    }
 
    private static ImmutableArray<string> GetRestorePaths(RestoreParams request, Solution solution, RequestContext context)
    {
        if (request.ProjectFilePaths.Any())
        {
            return [.. request.ProjectFilePaths];
        }
 
        // No file paths were specified - this means we should restore all projects in the solution.
        // If there is a valid solution path, use that as the restore path.
        if (solution.FilePath != null)
        {
            return [solution.FilePath];
        }
 
        // We don't have an addressable solution, so lets find all addressable projects.
        // We can only restore projects with file paths as we are using the dotnet CLI to address them.
        // We also need to remove duplicates as in multi targeting scenarios there will be multiple projects with the same file path.
        var projects = solution.Projects
            .Select(p => p.FilePath)
            .WhereNotNull()
            .Distinct()
            .ToImmutableArray();
 
        context.TraceDebug($"Found {projects.Length} restorable projects from {solution.Projects.Count()} projects in solution");
        return projects;
    }
}