File: Handler\CodeActions\CodeActionResolveHelper.cs
Web Access
Project: src\src\LanguageServer\Protocol\Microsoft.CodeAnalysis.LanguageServer.Protocol.csproj (Microsoft.CodeAnalysis.LanguageServer.Protocol)
// 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.CodeActions;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
using LSP = Roslyn.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler.CodeActions;
 
internal sealed class CodeActionResolveHelper
{
    public static Task<LSP.WorkspaceEdit> GetCodeActionResolveEditsAsync(RequestContext context, CodeActionResolveData data, ImmutableArray<CodeActionOperation> operations, CancellationToken cancellationToken)
    {
        var solution = context.Solution;
        Contract.ThrowIfNull(solution);
 
        return GetCodeActionResolveEditsAsync(
            solution,
            data,
            operations,
            context.GetRequiredClientCapabilities().Workspace?.WorkspaceEdit?.ResourceOperations ?? [],
            context.TraceInformation,
            cancellationToken);
    }
 
    public static async Task<LSP.WorkspaceEdit> GetCodeActionResolveEditsAsync(Solution solution, CodeActionResolveData data, ImmutableArray<CodeActionOperation> operations, ResourceOperationKind[] resourceOperations, Action<string> logFunction, CancellationToken cancellationToken)
    {
        // TO-DO: We currently must execute code actions which add new documents on the server as commands,
        // since there is no LSP support for adding documents yet. In the future, we should move these actions
        // to execute on the client.
        // https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1147293/
 
        var textDiffService = solution.Services.GetService<IDocumentTextDifferencingService>();
 
        using var _1 = ArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>.GetInstance(out var textDocumentEdits);
        using var _2 = PooledHashSet<DocumentId>.GetInstance(out var modifiedDocumentIds);
 
        foreach (var option in operations)
        {
            // We only support making solution-updating operations in LSP.  And only ones that modify documents. 1st
            // class code actions that do more than this are supposed to add the CodeAction.MakesNonDocumentChange
            // in their Tags so we can filter them out before returning them to the client.
            //
            // However, we cannot enforce this as 3rd party fixers can still run.  So we filter their results to 
            // only apply the portions of their work that updates documents, and nothing else.
            if (option is not ApplyChangesOperation applyChangesOperation)
            {
                logFunction($"Skipping code action operation for '{data.UniqueIdentifier}'.  It was a '{option.GetType().FullName}'");
                continue;
            }
 
            var changes = applyChangesOperation.ChangedSolution.GetChanges(solution);
            var newSolution = await applyChangesOperation.ChangedSolution.WithMergedLinkedFileChangesAsync(solution, changes, cancellationToken: cancellationToken).ConfigureAwait(false);
            changes = newSolution.GetChanges(solution);
 
            var projectChanges = changes.GetProjectChanges();
 
            // Don't apply changes in the presence of any non-document changes for now.  Note though that LSP does
            // support additional functionality (like create/rename/delete file).  Once VS updates their LSP client
            // impl to support this, we should add that support here.
            //
            // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspaceEdit
            //
            // Tracked with: https://github.com/dotnet/roslyn/issues/65303
            foreach (var projectChange in projectChanges)
            {
                if (projectChange.GetAddedProjectReferences().Any()
                    || projectChange.GetRemovedProjectReferences().Any()
                    || projectChange.GetAddedMetadataReferences().Any()
                    || projectChange.GetRemovedMetadataReferences().Any()
                    || projectChange.GetAddedAnalyzerReferences().Any()
                    || projectChange.GetRemovedAnalyzerReferences().Any())
                {
                    // Changes to references are not currently supported
                    return new LSP.WorkspaceEdit { DocumentChanges = Array.Empty<TextDocumentEdit>() };
                }
 
                if (projectChange.GetRemovedDocuments().Any()
                    || projectChange.GetRemovedAdditionalDocuments().Any()
                    || projectChange.GetRemovedAnalyzerConfigDocuments().Any())
                {
                    if (!resourceOperations.Contains(ResourceOperationKind.Delete))
                    {
                        // Removing documents is not supported by this workspace
                        return new LSP.WorkspaceEdit { DocumentChanges = Array.Empty<TextDocumentEdit>() };
                    }
                }
 
                if (projectChange.GetAddedDocuments().Any()
                    || projectChange.GetAddedAdditionalDocuments().Any()
                    || projectChange.GetAddedAnalyzerConfigDocuments().Any())
                {
                    if (!resourceOperations.Contains(ResourceOperationKind.Create))
                    {
                        // Adding documents is not supported by this workspace
                        return new LSP.WorkspaceEdit { DocumentChanges = Array.Empty<TextDocumentEdit>() };
                    }
                }
 
                if (projectChange.GetChangedDocuments().Any(docId => HasDocumentNameChange(docId, newSolution, solution))
                    || projectChange.GetChangedAdditionalDocuments().Any(docId => HasDocumentNameChange(docId, newSolution, solution)
                    || projectChange.GetChangedAnalyzerConfigDocuments().Any(docId => HasDocumentNameChange(docId, newSolution, solution))))
                {
                    if (!resourceOperations.Contains(ResourceOperationKind.Rename))
                    {
                        // Rename documents is not supported by this workspace
                        return new LSP.WorkspaceEdit { DocumentChanges = Array.Empty<TextDocumentEdit>() };
                    }
                }
            }
 
#if false

            // TO-DO: If the change involves adding or removing a document, execute via command instead of WorkspaceEdit
            // until adding/removing documents is supported in LSP: https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1147293/
            // After support is added, remove the below if-statement and add code to support adding/removing documents.
            var addedDocuments = projectChanges.SelectMany(
                pc => pc.GetAddedDocuments().Concat(pc.GetAddedAdditionalDocuments().Concat(pc.GetAddedAnalyzerConfigDocuments())));
            var removedDocuments = projectChanges.SelectMany(
                pc => pc.GetRemovedDocuments().Concat(pc.GetRemovedAdditionalDocuments().Concat(pc.GetRemovedAnalyzerConfigDocuments())));
            if (addedDocuments.Any() || removedDocuments.Any())
            {
                codeAction.Command = SetCommand(codeAction.Title, data);
                return codeAction;
            }
 
            // TO-DO: If the change involves adding or removing a project reference, execute via command instead of
            // WorkspaceEdit until adding/removing project references is supported in LSP:
            // https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1166040
            var projectReferences = projectChanges.SelectMany(
                pc => pc.GetAddedProjectReferences().Concat(pc.GetRemovedProjectReferences()));
            if (projectReferences.Any())
            {
                codeAction.Command = SetCommand(codeAction.Title, data);
                return codeAction;
            }
 
#endif
 
            // Removed documents
            await AddTextDocumentDeletionsAsync(
                projectChanges.SelectMany(pc => pc.GetRemovedDocuments()),
                solution.GetDocument).ConfigureAwait(false);
 
            // Removed analyzer config documents
            await AddTextDocumentDeletionsAsync(
                projectChanges.SelectMany(pc => pc.GetRemovedAnalyzerConfigDocuments()),
                solution.GetAnalyzerConfigDocument).ConfigureAwait(false);
 
            // Removed additional documents
            await AddTextDocumentDeletionsAsync(
                projectChanges.SelectMany(pc => pc.GetRemovedAdditionalDocuments()),
                solution.GetAdditionalDocument).ConfigureAwait(false);
 
            // Added documents
            await AddTextDocumentAdditionsAsync(
                projectChanges.SelectMany(pc => pc.GetAddedDocuments()),
                newSolution.GetDocument).ConfigureAwait(false);
 
            // Added analyzer config documents
            await AddTextDocumentAdditionsAsync(
                projectChanges.SelectMany(pc => pc.GetAddedAnalyzerConfigDocuments()),
                newSolution.GetAnalyzerConfigDocument).ConfigureAwait(false);
 
            // Added additional documents
            await AddTextDocumentAdditionsAsync(
                projectChanges.SelectMany(pc => pc.GetAddedAdditionalDocuments()),
                newSolution.GetAdditionalDocument).ConfigureAwait(false);
 
            // Changed documents
            await AddTextDocumentEditsAsync(
                projectChanges.SelectMany(pc => pc.GetChangedDocuments()),
                newSolution.GetDocument,
                solution.GetDocument).ConfigureAwait(false);
 
            // Razor calls through our code action handlers with documents that come from the Razor source generator
            // Those changes are not visible in project changes, because they happen in the compilation state, so we
            // make sure to pull changes out from that too. Changed source generated documents are "frozen" because
            // their content no longer comes from the source generator, so thats our cue to know when to handle.
            // Changes to non-frozen documents don't need to be included, because changes to the origin document would
            // cause the generator to re-generate the same changed content.
            await AddTextDocumentEditsAsync(
                changes.GetExplicitlyChangedSourceGeneratedDocuments(),
                newSolution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId,
                solution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId).ConfigureAwait(false);
 
            // Changed analyzer config documents
            await AddTextDocumentEditsAsync(
                projectChanges.SelectMany(pc => pc.GetChangedAnalyzerConfigDocuments()),
                newSolution.GetAnalyzerConfigDocument,
                solution.GetAnalyzerConfigDocument).ConfigureAwait(false);
 
            // Changed additional documents
            await AddTextDocumentEditsAsync(
                projectChanges.SelectMany(pc => pc.GetChangedAdditionalDocuments()),
                newSolution.GetAdditionalDocument,
                solution.GetAdditionalDocument).ConfigureAwait(false);
        }
 
        return new LSP.WorkspaceEdit { DocumentChanges = textDocumentEdits.ToArray() };
 
        Task AddTextDocumentDeletionsAsync<TTextDocument>(
            IEnumerable<DocumentId> removedDocuments,
            Func<DocumentId, TTextDocument?> getOldDocument)
            where TTextDocument : TextDocument
        {
            foreach (var docId in removedDocuments)
            {
                var oldTextDoc = getOldDocument(docId);
                Contract.ThrowIfNull(oldTextDoc);
 
                textDocumentEdits.Add(new DeleteFile { Uri = oldTextDoc.GetURI() });
            }
 
            return Task.CompletedTask;
        }
 
        async Task AddTextDocumentAdditionsAsync<TTextDocument>(
            IEnumerable<DocumentId> addedDocuments,
            Func<DocumentId, TTextDocument?> getNewDocument)
            where TTextDocument : TextDocument
        {
            foreach (var docId in addedDocuments)
            {
                var newTextDoc = getNewDocument(docId);
                Contract.ThrowIfNull(newTextDoc);
 
                Uri? uri = null;
                if (newTextDoc.FilePath != null)
                {
                    uri = newTextDoc.GetURI();
                }
                else if (newTextDoc.Project.FilePath != null)
                {
                    // If there is no file path with the document, try to find its path by using its project file.
                    uri = newTextDoc.CreateUriForDocumentWithoutFilePath();
                }
                else
                {
                    // No document file path, and no project path. We don't know how to add this document. Throw.
                    Contract.Fail($"Can't find uri for document: {newTextDoc.Name}.");
                }
 
                textDocumentEdits.Add(new CreateFile { Uri = uri });
 
                // And then give it content
                var newText = await newTextDoc.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
                var emptyDocumentRange = new LSP.Range { Start = new Position { Line = 0, Character = 0 }, End = new Position { Line = 0, Character = 0 } };
                var edit = new TextEdit { Range = emptyDocumentRange, NewText = newText.ToString() };
                var documentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = uri };
                textDocumentEdits.Add(new TextDocumentEdit { TextDocument = documentIdentifier, Edits = [edit] });
            }
        }
 
        async Task AddTextDocumentEditsAsync<TTextDocument>(
            IEnumerable<DocumentId> changedDocuments,
            Func<DocumentId, TTextDocument?> getNewDocument,
            Func<DocumentId, TTextDocument?> getOldDocument)
            where TTextDocument : TextDocument
        {
            foreach (var docId in changedDocuments)
            {
                var newTextDoc = getNewDocument(docId);
                var oldTextDoc = getOldDocument(docId);
 
                Contract.ThrowIfNull(oldTextDoc);
                Contract.ThrowIfNull(newTextDoc);
 
                // For linked documents, only generated the document edit once.
                if (modifiedDocumentIds.Add(docId))
                {
                    var oldText = await oldTextDoc.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
 
                    IEnumerable<TextChange> textChanges;
 
                    // Normal documents have a unique service for calculating minimal text edits. If we used the standard 'GetTextChanges'
                    // method instead, we would get a change that spans the entire document, which we ideally want to avoid.
                    if (newTextDoc is Document newDoc && oldTextDoc is Document oldDoc)
                    {
                        Contract.ThrowIfNull(textDiffService);
                        textChanges = await textDiffService.GetTextChangesAsync(oldDoc, newDoc, cancellationToken).ConfigureAwait(false);
                    }
                    else
                    {
                        var newText = await newTextDoc.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
                        textChanges = newText.GetTextChanges(oldText);
                    }
 
                    var edits = textChanges.Select(tc => new LSP.SumType<LSP.TextEdit, LSP.AnnotatedTextEdit>(ProtocolConversions.TextChangeToTextEdit(tc, oldText))).ToArray();
 
                    if (edits.Length > 0)
                    {
                        var documentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newTextDoc.GetURI() };
                        textDocumentEdits.Add(new TextDocumentEdit { TextDocument = documentIdentifier, Edits = edits });
                    }
 
                    // Add Rename edit.
                    // Note:
                    // Client is expected to do the change in the order in which they are provided.
                    // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspaceEdit
                    // So we would like to first edit the old document, then rename it.
                    if (oldTextDoc.Name != newTextDoc.Name)
                    {
                        textDocumentEdits.Add(new RenameFile() { OldUri = oldTextDoc.GetURI(), NewUri = newTextDoc.GetUriForRenamedDocument() });
                    }
 
                    var linkedDocuments = solution.GetRelatedDocumentIds(docId);
                    modifiedDocumentIds.AddRange(linkedDocuments);
                }
            }
        }
    }
 
    private static bool HasDocumentNameChange(DocumentId documentId, Solution newSolution, Solution oldSolution)
    {
        var newDocument = newSolution.GetRequiredTextDocument(documentId);
        var oldDocument = oldSolution.GetRequiredTextDocument(documentId);
        return newDocument.Name != oldDocument.Name;
    }
}