File: CodeActions\CodeActionEditHandlerService.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// 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.Immutable;
using System.ComponentModel.Composition;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Editor;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Editor.Undo;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Navigation;
using Microsoft.CodeAnalysis.Notification;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CodeActions;
 
[Export(typeof(ICodeActionEditHandlerService))]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal class CodeActionEditHandlerService(
    IThreadingContext threadingContext,
    IPreviewFactoryService previewService,
    IInlineRenameService renameService,
    ITextBufferAssociatedViewService associatedViewService) : ICodeActionEditHandlerService
{
    private readonly IThreadingContext _threadingContext = threadingContext;
    private readonly IPreviewFactoryService _previewService = previewService;
    private readonly IInlineRenameService _renameService = renameService;
 
    public ITextBufferAssociatedViewService AssociatedViewService { get; } = associatedViewService;
 
    public async Task<SolutionPreviewResult?> GetPreviewsAsync(
        Workspace workspace, ImmutableArray<CodeActionOperation> operations, CancellationToken cancellationToken)
    {
        if (operations.IsDefaultOrEmpty)
            return null;
 
        SolutionPreviewResult? currentResult = null;
 
        foreach (var op in operations)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            if (op is ApplyChangesOperation applyChanges)
            {
                var oldSolution = workspace.CurrentSolution;
                var newSolution = await applyChanges.ChangedSolution.WithMergedLinkedFileChangesAsync(
                    oldSolution, cancellationToken: cancellationToken).ConfigureAwait(false);
                var preview = _previewService.GetSolutionPreviews(
                    oldSolution, newSolution, cancellationToken);
 
                if (preview != null && !preview.IsEmpty)
                {
                    currentResult = SolutionPreviewResult.Merge(currentResult, preview);
                    continue;
                }
            }
 
            if (op is PreviewOperation previewOp)
            {
                currentResult = SolutionPreviewResult.Merge(currentResult,
                    new SolutionPreviewResult(_threadingContext, new SolutionPreviewItem(
                        projectId: null, documentId: null,
                        lazyPreview: c => previewOp.GetPreviewAsync(c))));
                continue;
            }
 
            var title = op.Title;
 
            if (title != null)
            {
                currentResult = SolutionPreviewResult.Merge(currentResult,
                    new SolutionPreviewResult(_threadingContext, new SolutionPreviewItem(
                        projectId: null, documentId: null, text: title)));
                continue;
            }
        }
 
        return currentResult;
    }
 
    public async Task<bool> ApplyAsync(
        Workspace workspace,
        Solution originalSolution,
        Document? fromDocument,
        ImmutableArray<CodeActionOperation> operations,
        string title,
        IProgress<CodeAnalysisProgress> progressTracker,
        CancellationToken cancellationToken)
    {
        // Much of the work we're going to do will be on the UI thread, so switch there preemptively.
        // When we get to the expensive parts we can do in the BG then we'll switch over to relinquish
        // the UI thread.
        await this._threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
 
        if (operations.IsDefaultOrEmpty)
        {
            return _renameService.ActiveSession is null;
        }
 
        if (_renameService.ActiveSession != null)
        {
            workspace.Services.GetService<INotificationService>()?.SendNotification(
                EditorFeaturesResources.Cannot_apply_operation_while_a_rename_session_is_active,
                severity: NotificationSeverity.Error);
            return false;
        }
 
        var oldSolution = workspace.CurrentSolution;
 
        var applied = false;
 
        // Determine if we're making a simple text edit to a single file or not.
        // If we're not, then we need to make a linked global undo to wrap the
        // application of these operations.  This way we should be able to undo
        // them all with one user action.
        //
        // The reason we don't always create a global undo is that a global undo
        // forces all files to save.  And that's rather a heavyweight and
        // unexpected experience for users (for the common case where a single
        // file got edited).
        var singleChangedDocument = TryGetSingleChangedText(oldSolution, operations);
        if (singleChangedDocument != null)
        {
            var text = await singleChangedDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(true);
 
            using (workspace.Services.GetRequiredService<ISourceTextUndoService>().RegisterUndoTransaction(text, title))
            {
                try
                {
                    _threadingContext.ThrowIfNotOnUIThread();
 
                    applied = await operations.Single().TryApplyAsync(
                        workspace, originalSolution, progressTracker, cancellationToken).ConfigureAwait(true);
                }
                catch (Exception ex) when (FatalError.ReportAndPropagateUnlessCanceled(ex, cancellationToken))
                {
                    throw ExceptionUtilities.Unreachable();
                }
            }
        }
        else
        {
            // More than just a single document changed.  Make a global undo to run
            // all the changes under.
            using var transaction = workspace.OpenGlobalUndoTransaction(title);
 
            // link current file in the global undo transaction
            // Do this before processing operations, since that can change
            // documentIds.
            if (fromDocument != null)
            {
                transaction.AddDocument(fromDocument.Id);
            }
 
            try
            {
                // Come back to the UI thread after processing the operations so we can commit the transaction
                applied = await ProcessOperationsAsync(
                    workspace, originalSolution, operations, progressTracker, cancellationToken).ConfigureAwait(true);
            }
            catch (Exception ex) when (FatalError.ReportAndPropagateUnlessCanceled(ex, cancellationToken))
            {
                throw ExceptionUtilities.Unreachable();
            }
 
            transaction.Commit();
        }
 
        var updatedSolution = operations.OfType<ApplyChangesOperation>().FirstOrDefault()?.ChangedSolution ?? oldSolution;
        await TryNavigateToLocationOrStartRenameSessionAsync(
            workspace, operations, oldSolution, updatedSolution, cancellationToken).ConfigureAwait(false);
        return applied;
    }
 
    private static TextDocument? TryGetSingleChangedText(
        Solution oldSolution, ImmutableArray<CodeActionOperation> operationsList)
    {
        Debug.Assert(operationsList.Length > 0);
        if (operationsList.Length > 1)
            return null;
 
        if (operationsList.Single() is not ApplyChangesOperation applyOperation)
            return null;
 
        var newSolution = applyOperation.ChangedSolution;
        var changes = newSolution.GetChanges(oldSolution);
 
        if (changes.GetAddedProjects().Any() ||
            changes.GetRemovedProjects().Any())
        {
            return null;
        }
 
        var projectChanges = changes.GetProjectChanges().ToImmutableArray();
        if (projectChanges.Length != 1)
        {
            return null;
        }
 
        var projectChange = projectChanges.Single();
        if (projectChange.GetAddedAdditionalDocuments().Any() ||
            projectChange.GetAddedAnalyzerReferences().Any() ||
            projectChange.GetAddedDocuments().Any() ||
            projectChange.GetAddedAnalyzerConfigDocuments().Any() ||
            projectChange.GetAddedMetadataReferences().Any() ||
            projectChange.GetAddedProjectReferences().Any() ||
            projectChange.GetRemovedAdditionalDocuments().Any() ||
            projectChange.GetRemovedAnalyzerReferences().Any() ||
            projectChange.GetRemovedDocuments().Any() ||
            projectChange.GetRemovedAnalyzerConfigDocuments().Any() ||
            projectChange.GetRemovedMetadataReferences().Any() ||
            projectChange.GetRemovedProjectReferences().Any())
        {
            return null;
        }
 
        var changedAdditionalDocuments = projectChange.GetChangedAdditionalDocuments().ToImmutableArray();
        var changedDocuments = projectChange.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true).ToImmutableArray();
        var changedAnalyzerConfigDocuments = projectChange.GetChangedAnalyzerConfigDocuments().ToImmutableArray();
 
        if (changedAdditionalDocuments.Length + changedDocuments.Length + changedAnalyzerConfigDocuments.Length != 1)
        {
            return null;
        }
 
        if (changedDocuments.Any(static (id, arg) => arg.newSolution.GetRequiredDocument(id).HasInfoChanged(arg.oldSolution.GetRequiredDocument(id)), (oldSolution, newSolution)) ||
            changedAdditionalDocuments.Any(static (id, arg) => arg.newSolution.GetRequiredAdditionalDocument(id).HasInfoChanged(arg.oldSolution.GetRequiredAdditionalDocument(id)), (oldSolution, newSolution)) ||
            changedAnalyzerConfigDocuments.Any(static (id, arg) => arg.newSolution.GetRequiredAnalyzerConfigDocument(id).HasInfoChanged(arg.oldSolution.GetRequiredAnalyzerConfigDocument(id)), (oldSolution, newSolution)))
        {
            return null;
        }
 
        if (changedDocuments.Length == 1)
        {
            return oldSolution.GetDocument(changedDocuments[0]);
        }
        else if (changedAdditionalDocuments.Length == 1)
        {
            return oldSolution.GetAdditionalDocument(changedAdditionalDocuments[0]);
        }
        else
        {
            return oldSolution.GetAnalyzerConfigDocument(changedAnalyzerConfigDocuments[0]);
        }
    }
 
    /// <returns><see langword="true"/> if all expected <paramref name="operations"/> are applied successfully;
    /// otherwise, <see langword="false"/>.</returns>
    private async Task<bool> ProcessOperationsAsync(
        Workspace workspace,
        Solution originalSolution,
        ImmutableArray<CodeActionOperation> operations,
        IProgress<CodeAnalysisProgress> progressTracker,
        CancellationToken cancellationToken)
    {
        await this._threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
 
        var applied = true;
        var seenApplyChanges = false;
        foreach (var operation in operations)
        {
            if (operation is ApplyChangesOperation)
            {
                // there must be only one ApplyChangesOperation, we will ignore all other ones.
                if (seenApplyChanges)
                    continue;
 
                seenApplyChanges = true;
            }
 
            _threadingContext.ThrowIfNotOnUIThread();
            applied &= await operation.TryApplyAsync(workspace, originalSolution, progressTracker, cancellationToken).ConfigureAwait(true);
        }
 
        return applied;
    }
 
    private async Task TryNavigateToLocationOrStartRenameSessionAsync(
        Workspace workspace,
        ImmutableArray<CodeActionOperation> operations,
        Solution oldSolution,
        Solution newSolution,
        CancellationToken cancellationToken)
    {
        var navigationOperation = operations.OfType<DocumentNavigationOperation>().FirstOrDefault();
        if (navigationOperation != null && workspace.CanOpenDocuments)
        {
            var navigationService = workspace.Services.GetRequiredService<IDocumentNavigationService>();
            await navigationService.TryNavigateToPositionAsync(
                this._threadingContext, workspace, navigationOperation.DocumentId, navigationOperation.Position, cancellationToken).ConfigureAwait(false);
            return;
        }
 
        var renameOperation = operations.OfType<StartInlineRenameSessionOperation>().FirstOrDefault();
        if (renameOperation != null && workspace.CanOpenDocuments)
        {
            var navigationService = workspace.Services.GetRequiredService<IDocumentNavigationService>();
            if (await navigationService.TryNavigateToPositionAsync(
                    this._threadingContext, workspace, renameOperation.DocumentId, renameOperation.Position, cancellationToken).ConfigureAwait(true))
            {
                var openDocument = workspace.CurrentSolution.GetRequiredDocument(renameOperation.DocumentId);
                _renameService.StartInlineSession(openDocument, new TextSpan(renameOperation.Position, 0), cancellationToken);
                return;
            }
        }
 
        var changedDocuments = newSolution.GetChangedDocuments(oldSolution);
        foreach (var documentId in changedDocuments)
        {
            var document = newSolution.GetRequiredDocument(documentId);
            if (!document.SupportsSyntaxTree)
                continue;
 
            var root = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
 
            var navigationToken = root.GetAnnotatedTokens(NavigationAnnotation.Kind).FirstOrNull();
            if (navigationToken.HasValue)
            {
                var navigationService = workspace.Services.GetRequiredService<IDocumentNavigationService>();
                await navigationService.TryNavigateToPositionAsync(
                    this._threadingContext, workspace, documentId, navigationToken.Value.SpanStart, cancellationToken).ConfigureAwait(false);
                return;
            }
 
            var renameToken = root.GetAnnotatedTokens(RenameAnnotation.Kind).FirstOrNull();
            if (renameToken.HasValue)
            {
                // It's possible that the workspace's current solution is not the same as
                // newSolution. This can happen if the workspace host performs other edits
                // during ApplyChanges, such as in the Venus scenario where indentation and
                // formatting can happen. To work around this, we create a SyntaxPath to the
                // rename token in the newSolution and resolve it to the current solution.
 
                var pathToRenameToken = new SyntaxPath(renameToken.Value);
                var latestDocument = workspace.CurrentSolution.GetDocument(documentId);
                if (latestDocument != null)
                {
                    var latestRoot = await latestDocument.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
                    if (pathToRenameToken.TryResolve(latestRoot, out var resolvedRenameToken) &&
                        resolvedRenameToken.IsToken)
                    {
                        var editorWorkspace = workspace;
                        var navigationService = editorWorkspace.Services.GetRequiredService<IDocumentNavigationService>();
 
                        if (await navigationService.TryNavigateToSpanAsync(
                                this._threadingContext, editorWorkspace, documentId, resolvedRenameToken.Span, cancellationToken).ConfigureAwait(false))
                        {
                            var openDocument = workspace.CurrentSolution.GetRequiredDocument(documentId);
                            var openRoot = await openDocument.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
 
                            // NOTE: We need to resolve the syntax path again in case VB line commit kicked in
                            // due to the navigation.
 
                            // TODO(DustinCa): We still have a potential problem here with VB line commit,
                            // because it can insert tokens and all sorts of other business, which could
                            // wind up with us not being able to resolve the token.
                            if (pathToRenameToken.TryResolve(openRoot, out resolvedRenameToken) &&
                                resolvedRenameToken.IsToken)
                            {
                                var text = await openDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
                                var snapshot = text.FindCorrespondingEditorTextSnapshot();
                                if (snapshot != null)
                                {
                                    await this._threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
                                    _renameService.StartInlineSession(openDocument, resolvedRenameToken.AsToken().Span, cancellationToken);
                                }
                            }
                        }
                    }
                }
 
                return;
            }
        }
    }
}