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.ExtractClass;
using Microsoft.CodeAnalysis.ExtractInterface;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.UnifiedSuggestions;
using Microsoft.CodeAnalysis.UnifiedSuggestions.UnifiedSuggestedActions;
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!, suggestedAction),
                            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!, suggestedAction)));
                    }
                }
            }
        }
 
        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.
        // Exceptions are made for ExtractClass and ExtractInterface because we have OptionsServices which
        // provide reasonable defaults without user interaction.
        => (suggestedAction.OriginalCodeAction is CodeActionWithOptions
            && suggestedAction.OriginalCodeAction is not ExtractInterfaceCodeAction
            && suggestedAction.OriginalCodeAction is not ExtractClassWithDialogCodeAction)
        // 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], 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], 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)
            {
                if (IsCodeActionNotSupportedByLSP(suggestedAction))
                {
                    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, IUnifiedSuggestedAction suggestedAction)
        => categoryName switch
        {
            UnifiedPredefinedSuggestedActionCategoryNames.CodeFix => CodeActionKind.QuickFix,
            UnifiedPredefinedSuggestedActionCategoryNames.Refactoring => GetRefactoringKind(suggestedAction),
            UnifiedPredefinedSuggestedActionCategoryNames.StyleFix => CodeActionKind.QuickFix,
            UnifiedPredefinedSuggestedActionCategoryNames.ErrorFix => CodeActionKind.QuickFix,
            _ => throw ExceptionUtilities.UnexpectedValue(categoryName)
        };
 
    private static CodeActionKind GetRefactoringKind(IUnifiedSuggestedAction suggestedAction)
    {
        if (suggestedAction is not ICodeRefactoringSuggestedAction refactoringAction)
            return CodeActionKind.Refactor;
 
        return refactoringAction.CodeRefactoringProvider.Kind switch
        {
            CodeRefactoringKind.Extract => CodeActionKind.RefactorExtract,
            CodeRefactoringKind.Inline => CodeActionKind.RefactorInline,
            _ => CodeActionKind.Refactor,
        };
    }
 
    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;
    }
}