File: CodeActions\Operations\ApplyChangesOperation.cs
Web Access
Project: src\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.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Shared.Extensions;
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(Solution changedSolution) : CodeActionOperation
{
    public Solution ChangedSolution { get; } = changedSolution ?? throw new ArgumentNullException(nameof(changedSolution));
 
    internal override bool ApplyDuringTests => true;
 
    public override void Apply(Workspace workspace, CancellationToken cancellationToken)
        => workspace.TryApplyChanges(ChangedSolution, CodeAnalysisProgress.None);
 
    internal sealed override Task<bool> TryApplyAsync(Workspace workspace, Solution originalSolution, IProgress<CodeAnalysisProgress> progressTracker, CancellationToken cancellationToken)
        => Task.FromResult(ApplyOrMergeChanges(workspace, originalSolution, ChangedSolution, progressTracker, cancellationToken));
 
    internal static bool ApplyOrMergeChanges(
        Workspace workspace,
        Solution originalSolution,
        Solution changedSolution,
        IProgress<CodeAnalysisProgress> 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);
    }
}