File: CodeActions\Operations\ApplyChangesOperation.cs
Web Access
Project: ..\..\..\src\Workspaces\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj (Microsoft.CodeAnalysis.Workspaces)
// 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.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CodeActions
{
#pragma warning disable RS0030 // Do not used banned APIs
    /// <summary>
    /// A <see cref="CodeActionOperation"/> for applying solution changes to a workspace.
    /// <see cref="CodeAction.GetOperationsAsync(CancellationToken)"/> may return at most one
    /// <see cref="ApplyChangesOperation"/>. Hosts may provide custom handling for 
    /// <see cref="ApplyChangesOperation"/>s, but if a <see cref="CodeAction"/> requires custom
    /// host behavior not supported by a single <see cref="ApplyChangesOperation"/>, then instead:
    /// <list type="bullet">
    /// <description><text>Implement a custom <see cref="CodeAction"/> and <see cref="CodeActionOperation"/>s</text></description>
    /// <description><text>Do not return any <see cref="ApplyChangesOperation"/> from <see cref="CodeAction.GetOperationsAsync(CancellationToken)"/></text></description>
    /// <description><text>Directly apply any workspace edits</text></description>
    /// <description><text>Handle any custom host behavior</text></description>
    /// <description><text>Produce a preview for <see cref="CodeAction.GetPreviewOperationsAsync(CancellationToken)"/> 
    ///   by creating a custom <see cref="PreviewOperation"/> or returning a single <see cref="ApplyChangesOperation"/>
    ///   to use the built-in preview mechanism</text></description>
    /// </list>
    /// </summary>
#pragma warning restore RS0030 // Do not used banned APIs
    public sealed class ApplyChangesOperation : CodeActionOperation
    {
        public Solution ChangedSolution { get; }
 
        public ApplyChangesOperation(Solution changedSolution)
            => ChangedSolution = changedSolution ?? throw new ArgumentNullException(nameof(changedSolution));
 
        internal override bool ApplyDuringTests => true;
 
        public override void Apply(Workspace workspace, CancellationToken cancellationToken)
            => workspace.TryApplyChanges(ChangedSolution, new ProgressTracker());
 
        internal sealed override Task<bool> TryApplyAsync(Workspace workspace, Solution originalSolution, IProgressTracker progressTracker, CancellationToken cancellationToken)
            => Task.FromResult(ApplyOrMergeChanges(workspace, originalSolution, ChangedSolution, progressTracker, cancellationToken));
 
        internal static bool ApplyOrMergeChanges(
            Workspace workspace,
            Solution originalSolution,
            Solution changedSolution,
            IProgressTracker progressTracker,
            CancellationToken cancellationToken)
        {
            var currentSolution = workspace.CurrentSolution;
 
            // if there was no intermediary edit, just apply the change fully.
            if (changedSolution.WorkspaceVersion == currentSolution.WorkspaceVersion)
            {
                var result = workspace.TryApplyChanges(changedSolution, progressTracker);
 
                Logger.Log(
                    result ? FunctionId.ApplyChangesOperation_WorkspaceVersionMatch_ApplicationSucceeded : FunctionId.ApplyChangesOperation_WorkspaceVersionMatch_ApplicationFailed,
                    logLevel: LogLevel.Information);
 
                return result;
            }
 
            // Otherwise, we need to see what changes were actually made and see if we can apply them.  The general rules are:
            //
            // 1. we only support text changes when doing merges.  Any other changes to projects/documents are not
            //    supported because it's very unclear what impact they may have wrt other workspace updates that have
            //    already happened.
            //
            // 2. For text changes, we only support it if the current text of the document we're changing itself has not
            //    changed. This means we can merge in edits if there were changes to unrelated files, but not if there
            //    are changes to the current file.  We could consider relaxing this in the future, esp. if we make use
            //    of some sort of text-merging-library to handle this.  However, the user would then have to handle diff
            //    markers being inserted into their code that they then have to handle.
 
            var solutionChanges = changedSolution.GetChanges(originalSolution);
 
            if (solutionChanges.GetAddedProjects().Any() ||
                solutionChanges.GetAddedAnalyzerReferences().Any() ||
                solutionChanges.GetRemovedProjects().Any() ||
                solutionChanges.GetRemovedAnalyzerReferences().Any())
            {
                Logger.Log(FunctionId.ApplyChangesOperation_WorkspaceVersionMismatch_ApplicationFailed_IncompatibleSolutionChange, logLevel: LogLevel.Information);
                return false;
            }
 
            // Take the actual current solution the workspace is pointing to and fork it with just the text changes the
            // code action wanted to make.  Then apply that fork back into the workspace.
            var forkedSolution = currentSolution;
 
            foreach (var changedProject in solutionChanges.GetProjectChanges())
            {
                // We only support text changes.  If we see any other changes to this project, bail out immediately.
                if (changedProject.GetAddedAdditionalDocuments().Any() ||
                    changedProject.GetAddedAnalyzerConfigDocuments().Any() ||
                    changedProject.GetAddedAnalyzerReferences().Any() ||
                    changedProject.GetAddedDocuments().Any() ||
                    changedProject.GetAddedMetadataReferences().Any() ||
                    changedProject.GetAddedProjectReferences().Any() ||
                    changedProject.GetRemovedAdditionalDocuments().Any() ||
                    changedProject.GetRemovedAnalyzerConfigDocuments().Any() ||
                    changedProject.GetRemovedAnalyzerReferences().Any() ||
                    changedProject.GetRemovedDocuments().Any() ||
                    changedProject.GetRemovedMetadataReferences().Any() ||
                    changedProject.GetRemovedProjectReferences().Any())
                {
                    Logger.Log(FunctionId.ApplyChangesOperation_WorkspaceVersionMismatch_ApplicationFailed_IncompatibleProjectChange, logLevel: LogLevel.Information);
                    return false;
                }
 
                // We have to at least have some changed document
                var changedDocuments = changedProject.GetChangedDocuments()
                    .Concat(changedProject.GetChangedAdditionalDocuments())
                    .Concat(changedProject.GetChangedAnalyzerConfigDocuments()).ToImmutableArray();
 
                if (changedDocuments.Length == 0)
                {
                    Logger.Log(FunctionId.ApplyChangesOperation_WorkspaceVersionMismatch_ApplicationFailed_NoChangedDocument, logLevel: LogLevel.Information);
                    return false;
                }
 
                foreach (var documentId in changedDocuments)
                {
                    var originalDocument = changedProject.OldProject.Solution.GetRequiredTextDocument(documentId);
                    var changedDocument = changedProject.NewProject.Solution.GetRequiredTextDocument(documentId);
 
                    // it has to be a text change the operation wants to make.  If the operation is making some other
                    // sort of change, we can't merge this operation in.
                    if (!changedDocument.HasTextChanged(originalDocument, ignoreUnchangeableDocument: false))
                    {
                        Logger.Log(FunctionId.ApplyChangesOperation_WorkspaceVersionMismatch_ApplicationFailed_NoTextChange, logLevel: LogLevel.Information);
                        return false;
                    }
 
                    // If the document has gone away, we definitely cannot apply a text change to it.
                    var currentDocument = currentSolution.GetTextDocument(documentId);
                    if (currentDocument is null)
                    {
                        Logger.Log(FunctionId.ApplyChangesOperation_WorkspaceVersionMismatch_ApplicationFailed_DocumentRemoved, logLevel: LogLevel.Information);
                        return false;
                    }
 
                    // If the file contents changed in the current workspace, then we can't apply this change to it.
                    // Note: we could potentially try to do a 3-way merge in the future, including handling conflicts
                    // with that.  For now though, we'll leave that out of scope.
                    if (originalDocument.HasTextChanged(currentDocument, ignoreUnchangeableDocument: false))
                    {
                        Logger.Log(FunctionId.ApplyChangesOperation_WorkspaceVersionMismatch_ApplicationFailed_TextChangeConflict, logLevel: LogLevel.Information);
                        return false;
                    }
 
                    forkedSolution = forkedSolution.WithTextDocumentText(documentId, changedDocument.GetTextSynchronously(cancellationToken));
                }
            }
 
            Logger.Log(FunctionId.ApplyChangesOperation_WorkspaceVersionMismatch_ApplicationSucceeded, logLevel: LogLevel.Information);
            return workspace.TryApplyChanges(forkedSolution, progressTracker);
        }
    }
}