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 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);
 
                // 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;
        }
    }
}