File: CodeActions\CodeActionsTests.cs
Web Access
Project: src\src\LanguageServer\ProtocolUnitTests\Microsoft.CodeAnalysis.LanguageServer.Protocol.UnitTests.csproj (Microsoft.CodeAnalysis.LanguageServer.Protocol.UnitTests)
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.AddImport;
using Microsoft.CodeAnalysis.LanguageServer.Handler.CodeActions;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;
using LSP = Roslyn.LanguageServer.Protocol;
using System.Text.Json;
 
namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.CodeActions;
 
public class CodeActionsTests(ITestOutputHelper testOutputHelper) : AbstractLanguageServerProtocolTests(testOutputHelper)
{
    [WpfTheory, CombinatorialData]
    public async Task TestCodeActionHandlerAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            class A
            {
                void M()
                {
                    {|caret:|}int i = 1;
                }
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, initializationOptions: new InitializationOptions() { ClientCapabilities = new VSInternalClientCapabilities { SupportsVisualStudioExtensions = true } });
 
        var titlePath = new[] { CSharpAnalyzersResources.Use_implicit_type };
        var caretLocation = testLspServer.GetLocations("caret").Single();
        var expected = CreateCodeAction(
            title: CSharpAnalyzersResources.Use_implicit_type,
            kind: CodeActionKind.Refactor,
            children: [],
            data: CreateCodeActionResolveData(
                CSharpAnalyzersResources.Use_implicit_type,
                caretLocation,
                codeActionPath: titlePath,
                customTags: new[] { PredefinedCodeRefactoringProviderNames.UseImplicitType }),
            priority: VSInternalPriorityLevel.Low,
            groupName: "Roslyn2",
            applicableRange: new LSP.Range { Start = new Position { Line = 4, Character = 8 }, End = new Position { Line = 4, Character = 11 } },
            diagnostics: null);
 
        var results = await RunGetCodeActionsAsync(testLspServer, CreateCodeActionParams(caretLocation));
        var useImplicitType = results.FirstOrDefault(r => r.Title == CSharpAnalyzersResources.Use_implicit_type);
 
        AssertJsonEquals(expected, useImplicitType);
    }
 
    [WpfTheory, CombinatorialData]
    public async Task TestCodeActionHandlerAsync_NestedAction(bool mutatingLspWorkspace)
    {
        var markup =
            """
            class A
            {
                void M()
                {
                    int {|caret:|}i = 1;
                }
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, CapabilitiesWithVSExtensions);
 
        var caretLocation = testLspServer.GetLocations("caret").Single();
        var titlePath = new[] { FeaturesResources.Introduce_constant, string.Format(FeaturesResources.Introduce_constant_for_0, "1") };
        var expected = CreateCodeAction(
            title: string.Format(FeaturesResources.Introduce_constant_for_0, "1"),
            kind: CodeActionKind.Refactor,
            children: [],
            data: CreateCodeActionResolveData(
                string.Format(FeaturesResources.Introduce_constant_for_0, "1"),
                caretLocation,
                codeActionPath: titlePath),
            priority: VSInternalPriorityLevel.Normal,
            groupName: "Roslyn3",
            applicableRange: new LSP.Range { Start = new Position { Line = 4, Character = 12 }, End = new Position { Line = 4, Character = 12 } },
            diagnostics: null);
 
        var results = await RunGetCodeActionsAsync(testLspServer, CreateCodeActionParams(caretLocation));
 
        var topLevelAction = Assert.Single(results, action => action.Title == titlePath[0]);
        var introduceConstant = topLevelAction.Children.FirstOrDefault(
            r => JsonSerializer.Deserialize<CodeActionResolveData>((JsonElement)r.Data!, ProtocolConversions.LspJsonSerializerOptions)!.UniqueIdentifier == titlePath[1]);
 
        AssertJsonEquals(expected, introduceConstant);
    }
 
    [WpfTheory, CombinatorialData]
    public async Task TestCodeActionHasCorrectDiagnostics(bool mutatingLspWorkspace)
    {
        var markup =
            """
            class A
            {
                void M()
                {
                    {|caret:|}Task.Delay(1);
                }
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace);
 
        var caret = testLspServer.GetLocations("caret").Single();
        var codeActionParams = new CodeActionParams
        {
            TextDocument = CreateTextDocumentIdentifier(caret.Uri),
            Range = caret.Range,
            Context = new CodeActionContext
            {
                Diagnostics =
                [
                    new LSP.Diagnostic
                    {
                        Code = AddImportDiagnosticIds.CS0103
                    },
                    new LSP.Diagnostic
                    {
                        Code = "SomeCode"
                    }
                ]
            }
        };
 
        var results = await RunGetCodeActionsAsync(testLspServer, codeActionParams);
        var addImport = results.FirstOrDefault(r => r.Title.Contains($"using System.Threading.Tasks"));
        Assert.Equal(1, addImport.Diagnostics!.Length);
        Assert.Equal(AddImportDiagnosticIds.CS0103, addImport.Diagnostics.Single().Code!.Value);
    }
 
    [WpfTheory, CombinatorialData]
    public async Task TestNoSuppressionFixerInStandardLSP(bool mutatingLspWorkspace)
    {
        var markup = """
            class ABC
            {
                private static async void {|caret:XYZ|}()
                {
                }
            }
            """;
 
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace);
 
        var caret = testLspServer.GetLocations("caret").Single();
        var codeActionParams = new CodeActionParams
        {
            TextDocument = CreateTextDocumentIdentifier(caret.Uri),
            Range = caret.Range,
            Context = new CodeActionContext
            {
                Diagnostics =
                [
                    new LSP.Diagnostic
                    {
                        // async method lack of await.
                        Code = "CS1998"
                    }
                ]
            }
        };
 
        var results = await RunGetCodeActionsAsync(testLspServer, codeActionParams);
        Assert.Equal(3, results.Length);
        Assert.Equal("Make method synchronous", results[0].Title);
    }
 
    [WpfTheory, CombinatorialData]
    public async Task TestStandardLspNestedCodeAction(bool mutatingLspWorkspace)
    {
        var markup = """
            class ABC
            {
                private void XYZ()
                {
                    var a = {|caret:A()|};
                }
 
                private int A() => 1;
            }
            """;
 
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace);
 
        var caret = testLspServer.GetLocations("caret").Single();
        var codeActionParams = new CodeActionParams
        {
            TextDocument = CreateTextDocumentIdentifier(caret.Uri),
            Range = caret.Range,
            Context = new CodeActionContext
            {
            }
        };
 
        var results = await RunGetCodeActionsAsync(testLspServer, codeActionParams);
        var inline = results.FirstOrDefault(r => r.Title.Contains($"Inline 'A()'"));
        var data = GetCodeActionResolveData(inline);
        Assert.NotNull(data);
 
        // Asserts that there are NestedActions on Inline
        Assert.NotNull(data!.NestedCodeActions);
        Assert.NotEmpty(data!.NestedCodeActions);
 
        // Asserts that the second NestedAction's path is correct
        var nestedActionData = GetCodeActionResolveData(data!.NestedCodeActions!.Value[1]);
        Assert.NotNull(nestedActionData);
        Assert.Equal("Inline 'A()'", nestedActionData!.CodeActionPath[0]);
        Assert.Equal("Inline and keep 'A()'", nestedActionData!.CodeActionPath[1]);
 
        // Asserts that there is a Command present on an action with nested actions
        Assert.NotNull(inline.Command);
    }
 
    [WpfTheory, CombinatorialData]
    public async Task TestStandardLspNestedFixAllCodeAction(bool mutatingLspWorkspace)
    {
        var markup = """
            class ABC
            {
                private static async void {|caret:XYZ|}()
                {
                }
            }
            """;
 
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace);
 
        var caret = testLspServer.GetLocations("caret").Single();
        var codeActionParams = new CodeActionParams
        {
            TextDocument = CreateTextDocumentIdentifier(caret.Uri),
            Range = caret.Range,
            Context = new CodeActionContext
            {
                Diagnostics =
                [
                    new LSP.Diagnostic
                    {
                        // async method lack of await.
                        Code = "CS1998"
                    }
                ]
            }
        };
 
        var results = await RunGetCodeActionsAsync(testLspServer, codeActionParams);
        Assert.Equal(3, results.Length);
        Assert.Equal("Suppress or configure issues", results[2].Title);
        var data = GetCodeActionResolveData(results[2]);
        Assert.NotNull(data);
 
        // Asserts that there are NestedActions present
        Assert.NotNull(data!.NestedCodeActions);
 
        //Asserts that a Nested Action could be a Fix All Action
        Assert.Equal("Fix All: in Source", data.NestedCodeActions!.Value[1].Title);
    }
 
    [WpfTheory, CombinatorialData]
    public async Task TestStandardLspNestedResolveTopLevelCodeAction(bool mutatingLspWorkspace)
    {
        var markup = """
            class ABC
            {
                private void XYZ()
                {
                    var a = {|caret:A()|};
                }
 
                private int A() => 1;
            }
            """;
 
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace);
 
        var caret = testLspServer.GetLocations("caret").Single();
        var codeActionParams = new CodeActionParams
        {
            TextDocument = CreateTextDocumentIdentifier(caret.Uri),
            Range = caret.Range,
            Context = new CodeActionContext
            {
            }
        };
 
        var results = await RunGetCodeActionsAsync(testLspServer, codeActionParams);
        // Assert that nested code actions aren't enumerated.
        var inline = results.FirstOrDefault(r => r.Title.Contains($"Inline 'A()'"));
        var resolvedAction = await RunGetCodeActionResolveAsync(testLspServer, inline);
        Assert.Null(resolvedAction.Edit);
    }
 
    private static async Task<VSInternalCodeAction[]> RunGetCodeActionsAsync(
        TestLspServer testLspServer,
        CodeActionParams codeActionParams)
    {
        var result = await testLspServer.ExecuteRequestAsync<CodeActionParams, CodeAction[]>(
            LSP.Methods.TextDocumentCodeActionName, codeActionParams, CancellationToken.None);
        return result.Cast<VSInternalCodeAction>().ToArray();
    }
 
    private static async Task<VSInternalCodeAction> RunGetCodeActionResolveAsync(
        TestLspServer testLspServer,
        CodeAction codeAction)
    {
        var result = await testLspServer.ExecuteRequestAsync<CodeAction, CodeAction>(
            LSP.Methods.CodeActionResolveName, codeAction, CancellationToken.None);
        Assert.NotNull(result);
        return (VSInternalCodeAction)result!;
    }
 
    private static CodeActionResolveData? GetCodeActionResolveData(CodeAction codeAction)
    {
        return JsonSerializer.Deserialize<CodeActionResolveData>((JsonElement)codeAction.Data!, ProtocolConversions.LspJsonSerializerOptions);
    }
 
    internal static CodeActionParams CreateCodeActionParams(LSP.Location caret)
        => new CodeActionParams
        {
            TextDocument = CreateTextDocumentIdentifier(caret.Uri),
            Range = caret.Range,
            Context = new CodeActionContext
            {
                // TODO - Code actions should respect context.
            }
        };
 
    internal static VSInternalCodeAction CreateCodeAction(
        string title, CodeActionKind kind, VSInternalCodeAction[] children,
        CodeActionResolveData data, LSP.Diagnostic[]? diagnostics,
        VSInternalPriorityLevel? priority, string groupName, LSP.Range applicableRange,
        WorkspaceEdit? edit = null, Command? command = null)
    {
        var action = new VSInternalCodeAction
        {
            Title = title,
            Kind = kind,
            Children = children,
            Data = JsonSerializer.SerializeToElement(data, ProtocolConversions.LspJsonSerializerOptions),
            Diagnostics = diagnostics,
            Edit = edit,
            Group = groupName,
            Priority = priority,
            ApplicableRange = applicableRange,
            Command = command
        };
 
        return action;
    }
}