File: Handler\CodeActions\CodeActionHelpers.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.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.UnifiedSuggestions;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
using CodeAction = Microsoft.CodeAnalysis.CodeActions.CodeAction;
using LSP = Roslyn.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler.CodeActions
{
    internal static class CodeActionHelpers
    {
        /// <summary>
        /// Get, order, and filter code actions, and then transform them into VSCodeActions or CodeActions based on <paramref name="hasVsLspCapability"/>.
        /// </summary>
        /// <remarks>
        /// Used by CodeActionsHandler.
        /// </remarks>
        public static async Task<LSP.CodeAction[]> GetVSCodeActionsAsync(
            CodeActionParams request,
            TextDocument document,
            ICodeFixService codeFixService,
            ICodeRefactoringService codeRefactoringService,
            bool hasVsLspCapability,
            CancellationToken cancellationToken)
        {
            var actionSets = await GetActionSetsAsync(
                document, codeFixService, codeRefactoringService, request.Range, cancellationToken).ConfigureAwait(false);
            if (actionSets.IsDefaultOrEmpty)
                return [];
 
            using var _ = ArrayBuilder<LSP.CodeAction>.GetInstance(out var codeActions);
            // VS-LSP support nested code action, but standard LSP doesn't.
            if (hasVsLspCapability)
            {
                var documentText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
 
                // Each suggested action set should have a unique set number, which is used for grouping code actions together.
                var currentHighestSetNumber = 0;
 
                foreach (var set in actionSets)
                {
                    var currentSetNumber = ++currentHighestSetNumber;
                    foreach (var suggestedAction in set.Actions)
                    {
                        if (!IsCodeActionNotSupportedByLSP(suggestedAction))
                        {
                            codeActions.Add(GenerateVSCodeAction(
                                request, documentText,
                                suggestedAction: suggestedAction,
                                codeActionKind: GetCodeActionKindFromSuggestedActionCategoryName(set.CategoryName!),
                                setPriority: set.Priority,
                                applicableRange: set.ApplicableToSpan.HasValue ? ProtocolConversions.TextSpanToRange(set.ApplicableToSpan.Value, documentText) : null,
                                currentSetNumber: currentSetNumber,
                                codeActionPathList: [],
                                currentHighestSetNumber: ref currentHighestSetNumber));
                        }
                    }
                }
            }
            else
            {
                foreach (var set in actionSets)
                {
                    foreach (var suggestedAction in set.Actions)
                    {
                        if (!IsCodeActionNotSupportedByLSP(suggestedAction))
                        {
                            codeActions.AddRange(GenerateCodeActions(
                                request,
                                suggestedAction,
                                GetCodeActionKindFromSuggestedActionCategoryName(set.CategoryName!)));
                        }
                    }
                }
            }
 
            return codeActions.ToArray();
        }
 
        private static bool IsCodeActionNotSupportedByLSP(IUnifiedSuggestedAction suggestedAction)
            // Filter out code actions with options since they'll show dialogs and we can't remote the UI and the options.
            => suggestedAction.OriginalCodeAction is CodeActionWithOptions
            // Skip code actions that requires non-document changes.  We can't apply them in LSP currently.
            // https://github.com/dotnet/roslyn/issues/48698
            || suggestedAction.OriginalCodeAction.Tags.Contains(CodeAction.RequiresNonDocumentChange);
 
        /// <summary>
        /// Generate the matching code actions for <paramref name="suggestedAction"/>. If it contains nested code actions, flatten them into an array.
        /// </summary>
        private static LSP.CodeAction[] GenerateCodeActions(
            CodeActionParams request,
            IUnifiedSuggestedAction suggestedAction,
            LSP.CodeActionKind codeActionKind)
        {
            var codeAction = suggestedAction.OriginalCodeAction;
            var diagnosticsForFix = GetApplicableDiagnostics(request.Context, suggestedAction);
 
            using var _ = ArrayBuilder<LSP.CodeAction>.GetInstance(out var builder);
            var codeActionPathList = ImmutableArray.Create(codeAction.Title);
            var nestedCodeActions = CollectNestedActions(request, codeActionKind, diagnosticsForFix, suggestedAction, codeActionPathList, isTopLevelCodeAction: true);
 
            Command? nestedCodeActionCommand = null;
            var title = codeAction.Title;
 
            if (nestedCodeActions.Any())
            {
                nestedCodeActionCommand = new LSP.Command
                {
                    CommandIdentifier = CodeActionsHandler.RunNestedCodeActionCommandName,
                    Title = title,
                    Arguments = [new CodeActionResolveData(title, codeAction.CustomTags, request.Range, request.TextDocument, [.. codeActionPathList], fixAllFlavors: null, nestedCodeActions: nestedCodeActions)]
                };
            }
 
            AddLSPCodeActions(builder, codeAction, request, codeActionKind, diagnosticsForFix, nestedCodeActionCommand,
                nestedCodeActions, [.. codeActionPathList], suggestedAction);
 
            return builder.ToArray();
        }
 
        private static ImmutableArray<LSP.CodeAction> CollectNestedActions(
            CodeActionParams request,
            LSP.CodeActionKind codeActionKind,
            LSP.Diagnostic[]? diagnosticsForFix,
            IUnifiedSuggestedAction suggestedAction,
            ImmutableArray<string> pathOfParentAction,
            bool isTopLevelCodeAction = false)
        {
            var codeAction = suggestedAction.OriginalCodeAction;
            using var _1 = ArrayBuilder<LSP.CodeAction>.GetInstance(out var nestedCodeActions);
 
            if (suggestedAction is UnifiedSuggestedActionWithNestedActions unifiedSuggestedActions)
            {
                foreach (var actionSet in unifiedSuggestedActions.NestedActionSets)
                {
                    foreach (var action in actionSet.Actions)
                    {
                        nestedCodeActions.AddRange(CollectNestedActions(request, codeActionKind,
                            diagnosticsForFix, action, pathOfParentAction.Add(action.OriginalCodeAction.Title)));
                    }
                }
            }
            else
            {
                if (!isTopLevelCodeAction)
                {
                    AddLSPCodeActions(nestedCodeActions, codeAction, request, codeActionKind, diagnosticsForFix,
                        nestedCodeActionCommand: null, nestedCodeActions: null, [.. pathOfParentAction], suggestedAction);
                }
            }
 
            return nestedCodeActions.ToImmutableAndClear();
        }
 
        private static void AddLSPCodeActions(
            ArrayBuilder<LSP.CodeAction> builder,
            CodeAction codeAction,
            CodeActionParams request,
            LSP.CodeActionKind codeActionKind,
            LSP.Diagnostic[]? diagnosticsForFix,
            Command? nestedCodeActionCommand,
            ImmutableArray<LSP.CodeAction>? nestedCodeActions,
            string[] codeActionPath,
            IUnifiedSuggestedAction suggestedAction)
        {
            var title = codeAction.Title;
            // We add an overarching action to the lightbulb that may contain nested actions.
            // Selecting one of these actions from the list invokes a command on the client side to open
            // a quick pick to select a nested action.
            builder.Add(new LSP.CodeAction
            {
                Title = title,
                Kind = codeActionKind,
                Diagnostics = diagnosticsForFix,
                Command = nestedCodeActionCommand,
                Data = new CodeActionResolveData(title, codeAction.CustomTags, request.Range, request.TextDocument, codeActionPath, fixAllFlavors: null, nestedCodeActions)
            });
 
            if (suggestedAction is UnifiedCodeFixSuggestedAction unifiedCodeFixSuggestedAction && unifiedCodeFixSuggestedAction.FixAllFlavors is not null)
            {
                var fixAllFlavors = unifiedCodeFixSuggestedAction.FixAllFlavors.Actions.OfType<UnifiedFixAllCodeFixSuggestedAction>().Select(action => action.FixAllState.Scope.ToString());
                var fixAllTitle = string.Format(FeaturesResources.Fix_All_0, title);
                var command = new LSP.Command
                {
                    CommandIdentifier = CodeActionsHandler.RunFixAllCodeActionCommandName,
                    Title = fixAllTitle,
                    Arguments = [new CodeActionResolveData(fixAllTitle, codeAction.CustomTags, request.Range, request.TextDocument, codeActionPath, fixAllFlavors.ToArray(), nestedCodeActions: null)]
                };
 
                builder.Add(new LSP.CodeAction
                {
                    Title = fixAllTitle,
                    Command = command,
                    Kind = codeActionKind,
                    Diagnostics = diagnosticsForFix,
                    Data = new CodeActionResolveData(fixAllTitle, codeAction.CustomTags, request.Range, request.TextDocument, codeActionPath, fixAllFlavors.ToArray(), nestedCodeActions: null)
                });
            }
        }
        private static VSInternalCodeAction GenerateVSCodeAction(
            CodeActionParams request,
            SourceText documentText,
            IUnifiedSuggestedAction suggestedAction,
            LSP.CodeActionKind codeActionKind,
            CodeActionPriority setPriority,
            LSP.Range? applicableRange,
            int currentSetNumber,
            ImmutableArray<string> codeActionPathList,
            ref int currentHighestSetNumber)
        {
            var codeAction = suggestedAction.OriginalCodeAction;
 
            var diagnosticsForFix = GetApplicableDiagnostics(request.Context, suggestedAction);
            codeActionPathList = codeActionPathList.Add(codeAction.Title);
            var nestedActions = GenerateNestedVSCodeActions(request, documentText, suggestedAction,
                codeActionKind, ref currentHighestSetNumber, codeActionPathList);
 
            return new VSInternalCodeAction
            {
                Title = codeAction.Title,
                Kind = codeActionKind,
                Diagnostics = diagnosticsForFix,
                Children = nestedActions,
                Priority = UnifiedSuggestedActionSetPriorityToPriorityLevel(setPriority),
                Group = $"Roslyn{currentSetNumber}",
                ApplicableRange = applicableRange,
                Data = new CodeActionResolveData(codeAction.Title, codeAction.CustomTags, request.Range, request.TextDocument, fixAllFlavors: null, nestedCodeActions: null, codeActionPath: [.. codeActionPathList])
            };
 
            static VSInternalCodeAction[] GenerateNestedVSCodeActions(
                CodeActionParams request,
                SourceText documentText,
                IUnifiedSuggestedAction suggestedAction,
                CodeActionKind codeActionKind,
                ref int currentHighestSetNumber,
                ImmutableArray<string> codeActionPath)
            {
                if (suggestedAction is not UnifiedSuggestedActionWithNestedActions suggestedActionWithNestedActions)
                {
                    return [];
                }
 
                using var _ = ArrayBuilder<VSInternalCodeAction>.GetInstance(out var nestedActions);
                foreach (var nestedActionSet in suggestedActionWithNestedActions.NestedActionSets)
                {
                    // Nested code action sets should each have a unique set number that is not yet assigned to any set.
                    var nestedSetNumber = ++currentHighestSetNumber;
                    foreach (var nestedSuggestedAction in nestedActionSet.Actions)
                    {
                        nestedActions.Add(GenerateVSCodeAction(
                            request, documentText, nestedSuggestedAction, codeActionKind, nestedActionSet.Priority,
                            applicableRange: nestedActionSet.ApplicableToSpan.HasValue
                                ? ProtocolConversions.TextSpanToRange(nestedActionSet.ApplicableToSpan.Value, documentText) : null,
                            nestedSetNumber, codeActionPath, ref currentHighestSetNumber));
                    }
                }
 
                return nestedActions.ToArray();
            }
        }
 
        private static LSP.Diagnostic[]? GetApplicableDiagnostics(CodeActionContext context, IUnifiedSuggestedAction action)
        {
            if (action is UnifiedCodeFixSuggestedAction codeFixAction && context.Diagnostics != null)
            {
                // Associate the diagnostics from the request that match the diagnostic fixed by the code action by ID.
                // The request diagnostics are already restricted to the code fix location by the request.
                var diagnosticCodesFixedByAction = codeFixAction.CodeFix.Diagnostics.Select(d => d.Id);
                using var _ = ArrayBuilder<LSP.Diagnostic>.GetInstance(out var diagnosticsBuilder);
                foreach (var requestDiagnostic in context.Diagnostics)
                {
                    var diagnosticCode = requestDiagnostic.Code?.Value?.ToString();
                    if (diagnosticCodesFixedByAction.Contains(diagnosticCode))
                    {
                        diagnosticsBuilder.Add(requestDiagnostic);
                    }
                }
 
                return diagnosticsBuilder.ToArray();
            }
 
            return null;
        }
 
        /// <summary>
        /// Get, order, and filter code actions.
        /// </summary>
        /// <remarks>
        /// Used by CodeActionResolveHandler and RunCodeActionHandler.
        /// </remarks>
        public static async Task<ImmutableArray<CodeAction>> GetCodeActionsAsync(
            TextDocument document,
            LSP.Range selection,
            ICodeFixService codeFixService,
            ICodeRefactoringService codeRefactoringService,
            string? fixAllScope,
            CancellationToken cancellationToken)
        {
            var actionSets = await GetActionSetsAsync(
                document, codeFixService, codeRefactoringService, selection, cancellationToken).ConfigureAwait(false);
            if (actionSets.IsDefaultOrEmpty)
                return [];
 
            var _ = ArrayBuilder<CodeAction>.GetInstance(out var codeActions);
            foreach (var set in actionSets)
            {
                foreach (var suggestedAction in set.Actions)
                {
                    // Filter out code actions with options since they'll show dialogs and we can't remote the UI and the options.
                    if (suggestedAction.OriginalCodeAction is CodeActionWithOptions)
                    {
                        continue;
                    }
 
                    codeActions.Add(GetNestedActionsFromActionSet(suggestedAction, fixAllScope));
 
                    if (fixAllScope != null)
                    {
                        GetFixAllActionsFromActionSet(suggestedAction, codeActions, fixAllScope);
                    }
                }
            }
 
            return codeActions.ToImmutableAndClear();
        }
 
        /// <summary>
        /// Generates a code action with its nested actions properly set.
        /// </summary>
        private static CodeAction GetNestedActionsFromActionSet(IUnifiedSuggestedAction suggestedAction, string? fixAllScope)
        {
            var codeAction = suggestedAction.OriginalCodeAction;
            if (suggestedAction is not UnifiedSuggestedActionWithNestedActions suggestedActionWithNestedActions)
            {
                return codeAction;
            }
 
            using var _ = ArrayBuilder<CodeAction>.GetInstance(out var nestedActions);
            foreach (var actionSet in suggestedActionWithNestedActions.NestedActionSets)
            {
                foreach (var action in actionSet.Actions)
                {
                    nestedActions.Add(GetNestedActionsFromActionSet(action, fixAllScope));
                    if (fixAllScope != null)
                    {
                        GetFixAllActionsFromActionSet(action, nestedActions, fixAllScope);
                    }
                }
            }
 
            return CodeAction.Create(
                codeAction.Title, nestedActions.ToImmutable(), codeAction.IsInlinable, codeAction.Priority);
        }
 
        private static void GetFixAllActionsFromActionSet(IUnifiedSuggestedAction suggestedAction, ArrayBuilder<CodeAction> codeActions, string? fixAllScope)
        {
            var codeAction = suggestedAction.OriginalCodeAction;
            if (suggestedAction is not UnifiedCodeFixSuggestedAction { FixAllFlavors: not null } unifiedCodeFixSuggestedAction)
            {
                return;
            }
 
            // Retrieves the fix all code action based on the scope that was selected. 
            // Creates a FixAllCodeAction type so that we can get the correct operations for the selected scope.
            var fixAllFlavor = unifiedCodeFixSuggestedAction.FixAllFlavors.Actions.OfType<UnifiedFixAllCodeFixSuggestedAction>().Where(action => action.FixAllState.Scope.ToString() == fixAllScope).First();
            codeActions.Add(new FixAllCodeAction(codeAction.Title, fixAllFlavor.FixAllState, showPreviewChangesDialog: false));
        }
 
        private static async ValueTask<ImmutableArray<UnifiedSuggestedActionSet>> GetActionSetsAsync(
            TextDocument document,
            ICodeFixService codeFixService,
            ICodeRefactoringService codeRefactoringService,
            LSP.Range selection,
            CancellationToken cancellationToken)
        {
            var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
            var textSpan = ProtocolConversions.RangeToTextSpan(selection, text);
 
            var codeFixes = await UnifiedSuggestedActionsSource.GetFilterAndOrderCodeFixesAsync(
                document.Project.Solution.Workspace, codeFixService, document, textSpan,
                new DefaultCodeActionRequestPriorityProvider(),
                cancellationToken).ConfigureAwait(false);
 
            var codeRefactorings = await UnifiedSuggestedActionsSource.GetFilterAndOrderCodeRefactoringsAsync(
                document.Project.Solution.Workspace, codeRefactoringService, document, textSpan, priority: null,
                filterOutsideSelection: false, cancellationToken).ConfigureAwait(false);
 
            var actionSets = UnifiedSuggestedActionsSource.FilterAndOrderActionSets(
                codeFixes, codeRefactorings, textSpan, currentActionCount: 0);
            return actionSets;
        }
 
        private static CodeActionKind GetCodeActionKindFromSuggestedActionCategoryName(string categoryName)
            => categoryName switch
            {
                UnifiedPredefinedSuggestedActionCategoryNames.CodeFix => CodeActionKind.QuickFix,
                UnifiedPredefinedSuggestedActionCategoryNames.Refactoring => CodeActionKind.Refactor,
                UnifiedPredefinedSuggestedActionCategoryNames.StyleFix => CodeActionKind.QuickFix,
                UnifiedPredefinedSuggestedActionCategoryNames.ErrorFix => CodeActionKind.QuickFix,
                _ => throw ExceptionUtilities.UnexpectedValue(categoryName)
            };
 
        private static LSP.VSInternalPriorityLevel? UnifiedSuggestedActionSetPriorityToPriorityLevel(CodeActionPriority priority)
            => priority switch
            {
                CodeActionPriority.Lowest => LSP.VSInternalPriorityLevel.Lowest,
                CodeActionPriority.Low => LSP.VSInternalPriorityLevel.Low,
                CodeActionPriority.Default => LSP.VSInternalPriorityLevel.Normal,
                CodeActionPriority.High => LSP.VSInternalPriorityLevel.High,
                _ => throw ExceptionUtilities.UnexpectedValue(priority)
            };
 
        public static CodeAction GetCodeActionToResolve(string[] codeActionPath, ImmutableArray<CodeAction> codeActions, bool isFixAllAction)
        {
            CodeAction? matchingAction = null;
            var currentActions = codeActions;
            for (var i = 0; i < codeActionPath.Length; i++)
            {
                var title = codeActionPath[i];
                var matchingActions = currentActions.Where(action => action.Title == title);
 
                // If we only have one matching action then just need to retrieve it from the list.
                if (matchingActions.Count() == 1)
                {
                    matchingAction = matchingActions.First();
                }
                else
                {
                    // Otherwise, we are likely at the end of the path and need to retrieve
                    // the FixAllCodeAction if we are in that state or just the regular CodeAction
                    // since they have the same title path.
                    matchingAction = matchingActions.Single(action => isFixAllAction ? action is FixAllCodeAction : action is CodeAction);
                }
 
                Contract.ThrowIfNull(matchingAction);
 
                currentActions = matchingAction.NestedActions;
                if (currentActions.IsEmpty)
                {
                    return matchingAction;
                }
            }
 
            Contract.ThrowIfNull(matchingAction);
            return matchingAction;
        }
    }
}