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.Extensions;
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 expected = @"```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)";
 
        var results = await RunResolveCompletionItemAsync(
            testLspServer,
            clientCompletionItem).ConfigureAwait(false);
        Assert.Equal(expected, 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 expected = @"void A.AMethod(int i)
A cref A.AMethod(int)
strong text
italic text
underline text
 
• Item 1.
• Item 2.
 
link text";
 
        var results = await RunResolveCompletionItemAsync(
            testLspServer,
            clientCompletionItem).ConfigureAwait(false);
        Assert.Equal(expected, 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);
    }
}