File: Completion\CompletionResolveTests.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.
 
#nullable disable
 
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.LanguageServer.Handler.Completion;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Text;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Test.Utilities;
using Roslyn.Text.Adornments;
using Xunit;
using Xunit.Abstractions;
using LSP = Roslyn.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.Completion;
 
public sealed class CompletionResolveTests : AbstractLanguageServerProtocolTests
{
    public CompletionResolveTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
    {
    }
 
    [Theory, CombinatorialData]
    public async Task TestResolveCompletionItemFromListAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            class A
            {
                void M()
                {
                    {|caret:|}
                }
            }
            """;
 
        var clientCapabilities = new LSP.VSInternalClientCapabilities
        {
            SupportsVisualStudioExtensions = true,
            TextDocument = new TextDocumentClientCapabilities()
            {
                Completion = new VSInternalCompletionSetting()
                {
                    CompletionList = new VSInternalCompletionListSetting()
                    {
                        Data = true,
                    }
                }
            }
        };
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, clientCapabilities);
 
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.VSInternalCompletionItem>(
            testLspServer,
            label: "A").ConfigureAwait(false);
 
        var description = new ClassifiedTextElement(CreateClassifiedTextRunForClass("A"));
        var expected = CreateResolvedCompletionItem(clientCompletionItem, description, null);
 
        var results = (LSP.VSInternalCompletionItem)await RunResolveCompletionItemAsync(
            testLspServer, clientCompletionItem).ConfigureAwait(false);
        AssertJsonEquals(expected, results);
    }
 
    [Theory, CombinatorialData]
    public async Task TestResolveCompletionItemAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            class A
            {
                void M()
                {
                    {|caret:|}
                }
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, new LSP.VSInternalClientCapabilities { SupportsVisualStudioExtensions = true });
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.VSInternalCompletionItem>(testLspServer, label: "A").ConfigureAwait(false);
 
        var description = new ClassifiedTextElement(CreateClassifiedTextRunForClass("A"));
        var expected = CreateResolvedCompletionItem(clientCompletionItem, description, null);
 
        var results = (LSP.VSInternalCompletionItem)await RunResolveCompletionItemAsync(
            testLspServer, clientCompletionItem).ConfigureAwait(false);
        AssertJsonEquals(expected, results);
    }
 
    [Theory, CombinatorialData]
    public async Task TestResolveOverridesCompletionItemAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            abstract class A
            {
                public abstract void M();
            }
 
            class B : A
            {
                override {|caret:|}
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, new LSP.VSInternalClientCapabilities { SupportsVisualStudioExtensions = true });
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.VSInternalCompletionItem>(testLspServer, label: "M()").ConfigureAwait(false);
        var results = (LSP.VSInternalCompletionItem)await RunResolveCompletionItemAsync(
            testLspServer, clientCompletionItem).ConfigureAwait(false);
 
        Assert.NotNull(results.TextEdit);
        Assert.Null(results.InsertText);
        Assert.Equal("""
            public override void M()
                {
                    throw new System.NotImplementedException();
                }
            """, results.TextEdit.Value.First.NewText);
    }
 
    [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/roslyn/issues/51125")]
    public async Task TestResolveOverridesCompletionItem_SnippetsEnabledAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            abstract class A
            {
                public abstract void M();
            }
 
            class B : A
            {
                override {|caret:|}
            }
            """;
 
        // Explicitly enable snippets. This allows us to set the cursor with $0. Currently only applies to C# in Razor docs.
        var clientCapabilities = new LSP.VSInternalClientCapabilities
        {
            SupportsVisualStudioExtensions = true,
            TextDocument = new LSP.TextDocumentClientCapabilities
            {
                Completion = new CompletionSetting
                {
                    CompletionItem = new CompletionItemSetting
                    {
                        SnippetSupport = true
                    }
                }
            }
        };
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, clientCapabilities);
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.VSInternalCompletionItem>(
            testLspServer,
            label: "M()").ConfigureAwait(false);
 
        var results = (LSP.VSInternalCompletionItem)await RunResolveCompletionItemAsync(
            testLspServer, clientCompletionItem).ConfigureAwait(false);
 
        Assert.NotNull(results.TextEdit);
        Assert.Null(results.InsertText);
        Assert.Equal("""
            public override void M()
                {
                    throw new System.NotImplementedException();$0
                }
            """, results.TextEdit.Value.First.NewText);
    }
 
    [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/roslyn/issues/51125")]
    public async Task TestResolveOverridesCompletionItem_SnippetsEnabled_CaretOutOfSnippetScopeAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            abstract class A
            {
                public abstract void M();
            }
 
            class B : A
            {
                override {|caret:|}
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace);
 
        var document = testLspServer.GetCurrentSolution().Projects.First().Documents.First();
 
        var selectedItem = CodeAnalysis.Completion.CompletionItem.Create(displayText: "M", isComplexTextEdit: true);
        var (textEdit, _, _) = await CompletionResultFactory.GenerateComplexTextEditAsync(
            document, new TestCaretOutOfScopeCompletionService(testLspServer.TestWorkspace.Services.SolutionServices), selectedItem, snippetsSupported: true, insertNewPositionPlaceholder: true, CancellationToken.None).ConfigureAwait(false);
 
        Assert.Equal("""
            public override void M()
                {
                    throw new System.NotImplementedException();
                }
            """, textEdit.NewText);
    }
 
    [Theory, CombinatorialData]
    public async Task TestResolveCompletionItemWithMarkupContentAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
 
            class A
            {
                /// <summary>
                /// A cref <see cref="AMethod"/>
                /// <br/>
                /// <strong>strong text</strong>
                /// <br/>
                /// <em>italic text</em>
                /// <br/>
                /// <u>underline text</u>
                /// <para>
                /// <list type='bullet'>
                /// <item>
                /// <description>Item 1.</description>
                /// </item>
                /// <item>
                /// <description>Item 2.</description>
                /// </item>
                /// </list>
                /// <a href = "https://google.com" > link text</a>
                /// </para>
                /// </summary>
                void AMethod(int i)
                {
                }
 
                void M()
                {
                    AMet{|caret:|}
                }
            }
            """;
        var clientCapabilities = new ClientCapabilities
        {
            TextDocument = new TextDocumentClientCapabilities
            {
                Completion = new CompletionSetting
                {
                    CompletionItem = new CompletionItemSetting
                    {
                        DocumentationFormat = [MarkupKind.Markdown]
                    }
                }
            }
        };
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, clientCapabilities);
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.CompletionItem>(
            testLspServer,
            label: "AMethod").ConfigureAwait(false);
        var results = await RunResolveCompletionItemAsync(
            testLspServer,
            clientCompletionItem).ConfigureAwait(false);
        Assert.Equal("""
            ```csharp
            void A.AMethod(int i)
            ```
              
            A cref&nbsp;A\.AMethod\(int\)  
            **strong text**  
            _italic text_  
            <u>underline text</u>  
              
            •&nbsp;Item 1\.  
            •&nbsp;Item 2\.  
              
            [link text](https://google.com)
            """, results.Documentation.Value.Second.Value);
    }
 
    [Theory, CombinatorialData]
    public async Task TestResolveCompletionItemWithPlainTextAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
 
            class A
            {
                /// <summary>
                /// A cref <see cref="AMethod"/>
                /// <br/>
                /// <strong>strong text</strong>
                /// <br/>
                /// <em>italic text</em>
                /// <br/>
                /// <u>underline text</u>
                /// <para>
                /// <list type='bullet'>
                /// <item>
                /// <description>Item 1.</description>
                /// </item>
                /// <item>
                /// <description>Item 2.</description>
                /// </item>
                /// </list>
                /// <a href = "https://google.com" > link text</a>
                /// </para>
                /// </summary>
                void AMethod(int i)
                {
                }
 
                void M()
                {
                    AMet{|caret:|}
                }
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace);
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.CompletionItem>(
            testLspServer,
            label: "AMethod").ConfigureAwait(false);
        var results = await RunResolveCompletionItemAsync(
            testLspServer,
            clientCompletionItem).ConfigureAwait(false);
        Assert.Equal("""
            void A.AMethod(int i)
            A cref A.AMethod(int)
            strong text
            italic text
            underline text
 
            • Item 1.
            • Item 2.
 
            link text
            """, results.Documentation.Value.Second.Value);
    }
 
    [Theory, CombinatorialData]
    public async Task TestResolveCompletionItemWithPrefixSuffixAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            class A
            {
                void M()
                {
                    var a = 10;
                    a.{|caret:|}
                }
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, new LSP.VSInternalClientCapabilities { SupportsVisualStudioExtensions = true });
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.VSInternalCompletionItem>(testLspServer, label: "(byte)").ConfigureAwait(false);
 
        var results = (LSP.VSInternalCompletionItem)await RunResolveCompletionItemAsync(
            testLspServer, clientCompletionItem).ConfigureAwait(false);
        Assert.Equal("(byte)", results.Label);
        Assert.NotNull(results.Description);
    }
 
    [Theory, CombinatorialData]
    public async Task TestSemanticSnippetChangeAsync(bool mutatingLspWorkspace)
    {
        var markup =
            """
            using System;
            public class Program
            {
                {|editRange:svm|}{|caret:|}
            }
            """;
        await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, new LSP.VSInternalClientCapabilities { SupportsVisualStudioExtensions = true });
        testLspServer.TestWorkspace.GlobalOptions.SetGlobalOption(CompletionOptionsStorage.SnippetsBehavior, LanguageNames.CSharp, SnippetsRule.AlwaysInclude);
        testLspServer.TestWorkspace.GlobalOptions.SetGlobalOption(CompletionOptionsStorage.ShowNewSnippetExperienceUserOption, LanguageNames.CSharp, true);
 
        var clientCompletionItem = await GetCompletionItemToResolveAsync<LSP.VSInternalCompletionItem>(testLspServer, label: "svm").ConfigureAwait(false);
 
        Assert.True(clientCompletionItem.VsResolveTextEditOnCommit);
 
        var results = (LSP.VSInternalCompletionItem)await RunResolveCompletionItemAsync(
            testLspServer, clientCompletionItem).ConfigureAwait(false);
 
        Assert.NotNull(results.TextEdit);
        Assert.Null(results.InsertText);
        Assert.Equal("static void Main(string[] args)\r\n    {\r\n        \r\n    }", results.TextEdit.Value.First.NewText);
 
        var editRange = testLspServer.GetLocations("editRange").Single().Range;
        Assert.Equal(editRange, results.TextEdit.Value.First.Range);
    }
 
    private static async Task<LSP.CompletionItem> RunResolveCompletionItemAsync(TestLspServer testLspServer, LSP.CompletionItem completionItem)
    {
        return await testLspServer.ExecuteRequestAsync<LSP.CompletionItem, LSP.CompletionItem>(LSP.Methods.TextDocumentCompletionResolveName,
                       completionItem, CancellationToken.None);
    }
 
    private static VSInternalCompletionItem Clone(VSInternalCompletionItem completionItem)
    {
        return new VSInternalCompletionItem()
        {
            Label = completionItem.Label,
            LabelDetails = completionItem.LabelDetails,
            Kind = completionItem.Kind,
            Detail = completionItem.Detail,
            Documentation = completionItem.Documentation,
            Preselect = completionItem.Preselect,
            SortText = completionItem.SortText,
            FilterText = completionItem.FilterText,
            InsertText = completionItem.InsertText,
            InsertTextFormat = completionItem.InsertTextFormat,
            TextEdit = completionItem.TextEdit,
            TextEditText = completionItem.TextEditText,
            AdditionalTextEdits = completionItem.AdditionalTextEdits,
            CommitCharacters = completionItem.CommitCharacters,
            Command = completionItem.Command,
            Data = completionItem.Data,
            Icon = completionItem.Icon,
            Description = completionItem.Description,
            VsCommitCharacters = completionItem.VsCommitCharacters,
            VsResolveTextEditOnCommit = completionItem.VsResolveTextEditOnCommit,
        };
    }
 
    private static VSInternalCompletionItem CreateResolvedCompletionItem(
        VSInternalCompletionItem completionItem,
        ClassifiedTextElement description,
        string documentation)
    {
        var expectedCompletionItem = Clone(completionItem);
 
        if (documentation != null)
        {
            expectedCompletionItem.Documentation = new MarkupContent()
            {
                Kind = LSP.MarkupKind.PlainText,
                Value = documentation
            };
        }
 
        expectedCompletionItem.Description = description;
        return expectedCompletionItem;
    }
 
    private static ClassifiedTextRun[] CreateClassifiedTextRunForClass(string className)
        =>
        [
            new ClassifiedTextRun("whitespace", string.Empty),
            new ClassifiedTextRun("keyword", "class"),
            new ClassifiedTextRun("whitespace", " "),
            new ClassifiedTextRun("class name", className),
            new ClassifiedTextRun("whitespace", string.Empty),
        ];
 
    private static async Task<T> GetCompletionItemToResolveAsync<T>(
        TestLspServer testLspServer,
        string label) where T : LSP.CompletionItem
    {
        var completionParams = CreateCompletionParams(
            testLspServer.GetLocations("caret").Single(), LSP.VSInternalCompletionInvokeKind.Explicit, "\0", LSP.CompletionTriggerKind.Invoked);
 
        var completionList = await RunGetCompletionsAsync(testLspServer, completionParams);
 
        if (testLspServer.ClientCapabilities.HasCompletionListDataCapability())
        {
            var vsCompletionList = Assert.IsAssignableFrom<VSInternalCompletionList>(completionList);
            Assert.NotNull(vsCompletionList.Data);
        }
 
        var clientCompletionItem = (T)completionList.Items.FirstOrDefault(item => item.Label == label);
        return clientCompletionItem;
    }
 
    private static async Task<LSP.CompletionList> RunGetCompletionsAsync(
        TestLspServer testLspServer,
        LSP.CompletionParams completionParams)
    {
        var completionList = await testLspServer.ExecuteRequestAsync<LSP.CompletionParams, LSP.CompletionList>(LSP.Methods.TextDocumentCompletionName,
            completionParams, CancellationToken.None);
 
        // Emulate client behavior of promoting "Data" completion list properties onto completion items.
        if (testLspServer.ClientCapabilities.HasCompletionListDataCapability() &&
            completionList is VSInternalCompletionList vsCompletionList &&
            vsCompletionList.Data != null)
        {
            foreach (var completionItem in completionList.Items)
            {
                Assert.Null(completionItem.Data);
                completionItem.Data = vsCompletionList.Data;
            }
        }
 
        return completionList;
    }
 
    private sealed class TestCaretOutOfScopeCompletionService : CompletionService
    {
        public TestCaretOutOfScopeCompletionService(SolutionServices services) : base(services, AsynchronousOperationListenerProvider.NullProvider)
        {
        }
 
        public override string Language => LanguageNames.CSharp;
 
        internal override Task<CodeAnalysis.Completion.CompletionList> GetCompletionsAsync(Document document,
            int caretPosition,
            CodeAnalysis.Completion.CompletionOptions options,
            OptionSet passThroughOptions,
            CompletionTrigger trigger = default,
            ImmutableHashSet<string> roles = null,
            CancellationToken cancellationToken = default) => Task.FromResult(CodeAnalysis.Completion.CompletionList.Empty);
 
        public override Task<CompletionChange> GetChangeAsync(
            Document document,
            CodeAnalysis.Completion.CompletionItem item,
            char? commitCharacter = null,
            CancellationToken cancellationToken = default)
        {
            var textChange = new TextChange(span: new TextSpan(start: 77, length: 9), newText: """
                public override void M()
                    {
                        throw new System.NotImplementedException();
                    }
                """);
 
            return Task.FromResult(CompletionChange.Create(textChange, newPosition: 0));
        }
 
        internal override bool ShouldTriggerCompletion(Project project, LanguageServices languageServices, SourceText text, int caretPosition, CompletionTrigger trigger, CodeAnalysis.Completion.CompletionOptions options, OptionSet passthroughOptions, ImmutableHashSet<string> roles = null)
            => false;
 
        internal override CompletionRules GetRules(CodeAnalysis.Completion.CompletionOptions options)
            => CompletionRules.Default;
 
        internal override Task<CompletionDescription> GetDescriptionAsync(Document document, CodeAnalysis.Completion.CompletionItem item, CodeAnalysis.Completion.CompletionOptions options, SymbolDescriptionOptions displayOptions, CancellationToken cancellationToken = default)
            => Task.FromResult(CompletionDescription.Empty);
    }
}