File: SignatureHelp\AbstractSignatureHelpProviderTests.cs
Web Access
Project: src\src\EditorFeatures\TestUtilities\Microsoft.CodeAnalysis.EditorFeatures.Test.Utilities.csproj (Microsoft.CodeAnalysis.EditorFeatures.Test.Utilities)
// 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;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense.SignatureHelp.Presentation;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.SignatureHelp;
using Microsoft.CodeAnalysis.Test.Utilities;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Test.Utilities;
using Roslyn.Utilities;
using Xunit;
 
namespace Microsoft.CodeAnalysis.Editor.UnitTests.SignatureHelp
{
    [UseExportProvider]
    public abstract class AbstractSignatureHelpProviderTests<TWorkspaceFixture> : TestBase
        where TWorkspaceFixture : TestWorkspaceFixture, new()
    {
        private readonly TestFixtureHelper<TWorkspaceFixture> _fixtureHelper = new();
 
        internal abstract Type GetSignatureHelpProviderType();
 
        private protected ReferenceCountedDisposable<TWorkspaceFixture> GetOrCreateWorkspaceFixture()
            => _fixtureHelper.GetOrCreateFixture();
 
        /// <summary>
        /// Verifies that sighelp comes up at the indicated location in markup ($$), with the indicated span [| ... |].
        /// </summary>
        /// <param name="markup">Input markup with $$ denoting the cursor position, and [| ... |]
        /// denoting the expected sighelp span</param>
        /// <param name="expectedOrderedItemsOrNull">The exact expected sighelp items list. If null, this part of the test is ignored.</param>
        /// <param name="usePreviousCharAsTrigger">If true, uses the last character before $$ to trigger sighelp.
        /// If false, invokes sighelp explicitly at the cursor location.</param>
        /// <param name="sourceCodeKind">The sourcecodekind to run this test on. If null, runs on both regular and script sources.</param>
        protected virtual async Task TestAsync(
            string markup,
            IEnumerable<SignatureHelpTestItem> expectedOrderedItemsOrNull = null,
            bool usePreviousCharAsTrigger = false,
            SourceCodeKind? sourceCodeKind = null,
            bool experimental = false)
        {
            using var workspaceFixture = GetOrCreateWorkspaceFixture();
 
            if (sourceCodeKind.HasValue)
            {
                await TestSignatureHelpWorkerAsync(markup, sourceCodeKind.Value, experimental, expectedOrderedItemsOrNull, usePreviousCharAsTrigger);
            }
            else
            {
                await TestSignatureHelpWorkerAsync(markup, SourceCodeKind.Regular, experimental, expectedOrderedItemsOrNull, usePreviousCharAsTrigger);
                await TestSignatureHelpWorkerAsync(markup, SourceCodeKind.Script, experimental, expectedOrderedItemsOrNull, usePreviousCharAsTrigger);
            }
        }
 
        private async Task TestSignatureHelpWorkerAsync(
            string markupWithPositionAndOptSpan,
            SourceCodeKind sourceCodeKind,
            bool experimental,
            IEnumerable<SignatureHelpTestItem> expectedOrderedItemsOrNull = null,
            bool usePreviousCharAsTrigger = false)
        {
            using var workspaceFixture = GetOrCreateWorkspaceFixture();
            var options = new MemberDisplayOptions();
 
            markupWithPositionAndOptSpan = markupWithPositionAndOptSpan.NormalizeLineEndings();
 
            TextSpan? textSpan = null;
            MarkupTestFile.GetPositionAndSpans(
                markupWithPositionAndOptSpan,
                out var code,
                out var cursorPosition,
                out var textSpans);
 
            if (textSpans.Any())
            {
                textSpan = textSpans.First();
            }
 
            var parseOptions = CreateExperimentalParseOptions();
 
            // regular
            var document1 = workspaceFixture.Target.UpdateDocument(code, sourceCodeKind);
            if (experimental)
            {
                document1 = document1.Project.WithParseOptions(parseOptions).GetDocument(document1.Id);
            }
 
            await TestSignatureHelpWorkerSharedAsync(workspaceFixture.Target.GetWorkspace(), code, cursorPosition, document1, options, textSpan, expectedOrderedItemsOrNull, usePreviousCharAsTrigger);
 
            // speculative semantic model
            if (await CanUseSpeculativeSemanticModelAsync(document1, cursorPosition))
            {
                var document2 = workspaceFixture.Target.UpdateDocument(code, sourceCodeKind, cleanBeforeUpdate: false);
                if (experimental)
                {
                    document2 = document2.Project.WithParseOptions(parseOptions).GetDocument(document2.Id);
                }
 
                await TestSignatureHelpWorkerSharedAsync(workspaceFixture.Target.GetWorkspace(), code, cursorPosition, document2, options, textSpan, expectedOrderedItemsOrNull, usePreviousCharAsTrigger);
            }
        }
 
        protected abstract ParseOptions CreateExperimentalParseOptions();
 
        private static async Task<bool> CanUseSpeculativeSemanticModelAsync(Document document, int position)
        {
            var service = document.GetLanguageService<ISyntaxFactsService>();
            var node = (await document.GetSyntaxRootAsync()).FindToken(position).Parent;
 
            return !service.GetMemberBodySpanForSpeculativeBinding(node).IsEmpty;
        }
 
        protected void VerifyTriggerCharacters(char[] expectedTriggerCharacters, char[] unexpectedTriggerCharacters)
        {
            using var workspaceFixture = GetOrCreateWorkspaceFixture();
 
            var signatureHelpProviderType = GetSignatureHelpProviderType();
            var signatureHelpProvider = workspaceFixture.Target.GetWorkspace().ExportProvider.GetExportedValues<ISignatureHelpProvider>().Single(provider => provider.GetType() == signatureHelpProviderType);
 
            foreach (var expectedTriggerCharacter in expectedTriggerCharacters)
            {
                Assert.True(signatureHelpProvider.IsTriggerCharacter(expectedTriggerCharacter), "Expected '" + expectedTriggerCharacter + "' to be a trigger character");
            }
 
            foreach (var unexpectedTriggerCharacter in unexpectedTriggerCharacters)
            {
                Assert.False(signatureHelpProvider.IsTriggerCharacter(unexpectedTriggerCharacter), "Expected '" + unexpectedTriggerCharacter + "' to NOT be a trigger character");
            }
        }
 
        protected virtual async Task VerifyCurrentParameterNameAsync(string markup, string expectedParameterName, SourceCodeKind? sourceCodeKind = null)
        {
            using var workspaceFixture = GetOrCreateWorkspaceFixture();
 
            if (sourceCodeKind.HasValue)
            {
                await VerifyCurrentParameterNameWorkerAsync(markup, expectedParameterName, sourceCodeKind.Value);
            }
            else
            {
                await VerifyCurrentParameterNameWorkerAsync(markup, expectedParameterName, SourceCodeKind.Regular);
                await VerifyCurrentParameterNameWorkerAsync(markup, expectedParameterName, SourceCodeKind.Script);
            }
        }
 
        private static async Task<SignatureHelpState?> GetArgumentStateAsync(int cursorPosition, Document document, ISignatureHelpProvider signatureHelpProvider, SignatureHelpTriggerInfo triggerInfo, MemberDisplayOptions options)
        {
            var items = await signatureHelpProvider.GetItemsAsync(document, cursorPosition, triggerInfo, options, CancellationToken.None);
            return items == null ? null : new SignatureHelpState(items.SemanticParameterIndex, items.SyntacticArgumentCount, items.ArgumentName, ArgumentNames: default);
        }
 
        private async Task VerifyCurrentParameterNameWorkerAsync(string markup, string expectedParameterName, SourceCodeKind sourceCodeKind)
        {
            using var workspaceFixture = GetOrCreateWorkspaceFixture();
 
            MarkupTestFile.GetPosition(markup.NormalizeLineEndings(), out var code, out int cursorPosition);
 
            var document = workspaceFixture.Target.UpdateDocument(code, sourceCodeKind);
 
            var signatureHelpProviderType = GetSignatureHelpProviderType();
            var signatureHelpProvider = workspaceFixture.Target.GetWorkspace().ExportProvider.GetExportedValues<ISignatureHelpProvider>().Single(provider => provider.GetType() == signatureHelpProviderType);
            var triggerInfo = new SignatureHelpTriggerInfo(SignatureHelpTriggerReason.InvokeSignatureHelpCommand);
            var options = new MemberDisplayOptions();
 
            _ = await signatureHelpProvider.GetItemsAsync(document, cursorPosition, triggerInfo, options, CancellationToken.None);
            Assert.Equal(expectedParameterName, (await GetArgumentStateAsync(cursorPosition, document, signatureHelpProvider, triggerInfo, options)).Value.ArgumentName);
        }
 
        private static void CompareAndAssertCollectionsAndCurrentParameter(
            IEnumerable<SignatureHelpTestItem> expectedTestItems, SignatureHelpItems actualSignatureHelpItems)
        {
            Assert.True(expectedTestItems.Count() == actualSignatureHelpItems.Items.Count(), $"Expected {expectedTestItems.Count()} items, but got {actualSignatureHelpItems.Items.Count()}");
 
            for (var i = 0; i < expectedTestItems.Count(); i++)
            {
                CompareSigHelpItemsAndCurrentPosition(
                    actualSignatureHelpItems,
                    actualSignatureHelpItems.Items.ElementAt(i),
                    expectedTestItems.ElementAt(i));
            }
        }
 
        private static void CompareSigHelpItemsAndCurrentPosition(
            SignatureHelpItems items,
            SignatureHelpItem actualSignatureHelpItem,
            SignatureHelpTestItem expectedTestItem)
        {
            var currentParameterIndex = -1;
            if (expectedTestItem.CurrentParameterIndex != null)
            {
                if (expectedTestItem.CurrentParameterIndex.Value >= 0 && expectedTestItem.CurrentParameterIndex.Value < actualSignatureHelpItem.Parameters.Length)
                {
                    currentParameterIndex = expectedTestItem.CurrentParameterIndex.Value;
                }
            }
 
            var signature = new Signature(applicableToSpan: null, signatureHelpItem: actualSignatureHelpItem, selectedParameterIndex: currentParameterIndex);
 
            // We're a match if the signature matches...
            // We're now combining the signature and documentation to make classification work.
            if (!string.IsNullOrEmpty(expectedTestItem.MethodDocumentation))
            {
                Assert.Equal(expectedTestItem.Signature + "\r\n" + expectedTestItem.MethodDocumentation, signature.Content);
            }
            else
            {
                AssertEx.Equal(expectedTestItem.Signature, signature.Content);
            }
 
            if (expectedTestItem.PrettyPrintedSignature != null)
            {
                Assert.Equal(expectedTestItem.PrettyPrintedSignature, signature.PrettyPrintedContent);
            }
 
            if (expectedTestItem.MethodDocumentation != null)
            {
                Assert.Equal(expectedTestItem.MethodDocumentation, actualSignatureHelpItem.DocumentationFactory(CancellationToken.None).GetFullText());
            }
 
            if (expectedTestItem.ParameterDocumentation != null)
            {
                Assert.Equal(expectedTestItem.ParameterDocumentation, signature.CurrentParameter.Documentation);
            }
 
            if (expectedTestItem.CurrentParameterIndex != null)
            {
                Assert.True(expectedTestItem.CurrentParameterIndex == items.SemanticParameterIndex, $"The current parameter is {items.SemanticParameterIndex}, but we expected {expectedTestItem.CurrentParameterIndex}");
            }
 
            if (expectedTestItem.Description != null)
            {
                Assert.Equal(expectedTestItem.Description, ToString(actualSignatureHelpItem.DescriptionParts));
            }
 
            // Always get and realise the classified spans, even if no expected spans are passed in, to at least validate that
            // exceptions aren't thrown
            var classifiedSpans = actualSignatureHelpItem.DocumentationFactory(CancellationToken.None).ToClassifiedSpans().ToList();
            if (expectedTestItem.ClassificationTypeNames is { } classificationTypeNames)
            {
                Assert.Equal(string.Join(", ", classificationTypeNames), string.Join(", ", classifiedSpans.Select(s => s.ClassificationType)));
            }
        }
 
        private static string ToString(IEnumerable<TaggedText> list)
            => string.Concat(list.Select(i => i.ToString()));
 
        protected async Task TestSignatureHelpInEditorBrowsableContextsAsync(
            string markup,
            string referencedCode,
            IEnumerable<SignatureHelpTestItem> expectedOrderedItemsMetadataReference,
            IEnumerable<SignatureHelpTestItem> expectedOrderedItemsSameSolution,
            string sourceLanguage, string referencedLanguage, bool hideAdvancedMembers = false)
        {
            if (expectedOrderedItemsMetadataReference == null || expectedOrderedItemsSameSolution == null)
            {
                AssertEx.Fail("Expected signature help items must be provided for EditorBrowsable tests. If there are no expected items, provide an empty IEnumerable rather than null.");
            }
 
            await TestSignatureHelpWithMetadataReferenceHelperAsync(markup, referencedCode, expectedOrderedItemsMetadataReference, sourceLanguage, referencedLanguage, hideAdvancedMembers);
            await TestSignatureHelpWithProjectReferenceHelperAsync(markup, referencedCode, expectedOrderedItemsSameSolution, sourceLanguage, referencedLanguage, hideAdvancedMembers);
 
            // Multi-language projects are not supported.
            if (sourceLanguage == referencedLanguage)
            {
                await TestSignatureHelpInSameProjectHelperAsync(markup, referencedCode, expectedOrderedItemsSameSolution, sourceLanguage, hideAdvancedMembers);
            }
        }
 
        public Task TestSignatureHelpWithMetadataReferenceHelperAsync(string sourceCode, string referencedCode, IEnumerable<SignatureHelpTestItem> expectedOrderedItems,
                                                                  string sourceLanguage, string referencedLanguage, bool hideAdvancedMembers)
        {
            var xmlString = string.Format(@"
<Workspace>
    <Project Language=""{0}"" CommonReferences=""true"">
        <Document FilePath=""SourceDocument"">
{1}
        </Document>
        <MetadataReferenceFromSource Language=""{2}"" CommonReferences=""true"">
            <Document FilePath=""ReferencedDocument"">
{3}
            </Document>
        </MetadataReferenceFromSource>
    </Project>
</Workspace>", sourceLanguage, SecurityElement.Escape(sourceCode),
               referencedLanguage, SecurityElement.Escape(referencedCode));
 
            return VerifyItemWithReferenceWorkerAsync(xmlString, expectedOrderedItems, hideAdvancedMembers);
        }
 
        public async Task TestSignatureHelpWithProjectReferenceHelperAsync(string sourceCode, string referencedCode, IEnumerable<SignatureHelpTestItem> expectedOrderedItems,
                                                                 string sourceLanguage, string referencedLanguage, bool hideAdvancedMembers)
        {
            var xmlString = string.Format(@"
<Workspace>
    <Project Language=""{0}"" CommonReferences=""true"">
        <ProjectReference>ReferencedProject</ProjectReference>
        <Document FilePath=""SourceDocument"">
{1}
        </Document>
    </Project>
    <Project Language=""{2}"" CommonReferences=""true"" AssemblyName=""ReferencedProject"">
        <Document FilePath=""ReferencedDocument"">
{3}
        </Document>
    </Project>
    
</Workspace>", sourceLanguage, SecurityElement.Escape(sourceCode),
               referencedLanguage, SecurityElement.Escape(referencedCode));
 
            await VerifyItemWithReferenceWorkerAsync(xmlString, expectedOrderedItems, hideAdvancedMembers);
        }
 
        private async Task TestSignatureHelpInSameProjectHelperAsync(string sourceCode, string referencedCode, IEnumerable<SignatureHelpTestItem> expectedOrderedItems,
                                                          string sourceLanguage, bool hideAdvancedMembers)
        {
            var xmlString = string.Format(@"
<Workspace>
    <Project Language=""{0}"" CommonReferences=""true"">
        <Document FilePath=""SourceDocument"">
{1}
        </Document>
        <Document FilePath=""ReferencedDocument"">
{2}
        </Document>
    </Project>
</Workspace>", sourceLanguage, SecurityElement.Escape(sourceCode), SecurityElement.Escape(referencedCode));
 
            await VerifyItemWithReferenceWorkerAsync(xmlString, expectedOrderedItems, hideAdvancedMembers);
        }
 
        protected async Task VerifyItemWithReferenceWorkerAsync(string xmlString, IEnumerable<SignatureHelpTestItem> expectedOrderedItems, bool hideAdvancedMembers)
        {
            using var testWorkspace = EditorTestWorkspace.Create(xmlString);
 
            var cursorPosition = testWorkspace.Documents.First(d => d.Name == "SourceDocument").CursorPosition.Value;
            var documentId = testWorkspace.Documents.First(d => d.Name == "SourceDocument").Id;
            var document = testWorkspace.CurrentSolution.GetDocument(documentId);
 
            var options = new MemberDisplayOptions() with { HideAdvancedMembers = hideAdvancedMembers };
            document = testWorkspace.CurrentSolution.GetDocument(documentId);
            var code = (await document.GetTextAsync()).ToString();
 
            IList<TextSpan> textSpans = null;
 
            var selectedSpans = testWorkspace.Documents.First(d => d.Name == "SourceDocument").SelectedSpans;
            if (selectedSpans.Any())
            {
                textSpans = selectedSpans;
            }
 
            TextSpan? textSpan = null;
            if (textSpans != null && textSpans.Any())
            {
                textSpan = textSpans.First();
            }
 
            await TestSignatureHelpWorkerSharedAsync(testWorkspace, code, cursorPosition, document, options, textSpan, expectedOrderedItems);
        }
 
        private async Task TestSignatureHelpWorkerSharedAsync(
            EditorTestWorkspace workspace,
            string code,
            int cursorPosition,
            Document document,
            MemberDisplayOptions options,
            TextSpan? textSpan,
            IEnumerable<SignatureHelpTestItem> expectedOrderedItemsOrNull = null,
            bool usePreviousCharAsTrigger = false)
        {
            var signatureHelpProviderType = GetSignatureHelpProviderType();
            var signatureHelpProvider = workspace.ExportProvider.GetExportedValues<ISignatureHelpProvider>().Single(provider => provider.GetType() == signatureHelpProviderType);
            var triggerInfo = new SignatureHelpTriggerInfo(SignatureHelpTriggerReason.InvokeSignatureHelpCommand);
 
            if (usePreviousCharAsTrigger)
            {
                triggerInfo = new SignatureHelpTriggerInfo(
                    SignatureHelpTriggerReason.TypeCharCommand,
                    code.ElementAt(cursorPosition - 1));
 
                if (!signatureHelpProvider.IsTriggerCharacter(triggerInfo.TriggerCharacter.Value))
                {
                    return;
                }
            }
 
            var items = await signatureHelpProvider.GetItemsAsync(document, cursorPosition, triggerInfo, options, CancellationToken.None);
 
            // If we're expecting 0 items, then there's no need to compare them
            if ((expectedOrderedItemsOrNull == null || !expectedOrderedItemsOrNull.Any()) && items == null)
            {
                return;
            }
 
            AssertEx.NotNull(items, "Signature help provider returned null for items. Did you forget $$ in the test or is the test otherwise malformed, e.g. quotes not escaped?");
 
            // Verify the span
            if (textSpan != null)
            {
                Assert.Equal(textSpan, items.ApplicableSpan);
            }
 
            if (expectedOrderedItemsOrNull != null)
            {
                CompareAndAssertCollectionsAndCurrentParameter(expectedOrderedItemsOrNull, items);
                CompareSelectedIndex(expectedOrderedItemsOrNull, items.SelectedItemIndex);
            }
        }
 
        private static void CompareSelectedIndex(IEnumerable<SignatureHelpTestItem> expectedOrderedItemsOrNull, int? selectedItemIndex)
        {
            if (expectedOrderedItemsOrNull == null ||
                !expectedOrderedItemsOrNull.Any(i => i.IsSelected))
            {
                return;
            }
 
            Assert.True(expectedOrderedItemsOrNull.Count(i => i.IsSelected) == 1, "Only one expected item can be marked with 'IsSelected'");
            Assert.True(selectedItemIndex != null, "Expected an item to be selected, but no item was actually selected");
 
            var counter = 0;
            foreach (var item in expectedOrderedItemsOrNull)
            {
                if (item.IsSelected)
                {
                    Assert.True(selectedItemIndex == counter,
                        $"Expected item with index {counter} to be selected, but the actual selected index is {selectedItemIndex}.");
                }
                else
                {
                    Assert.True(selectedItemIndex != counter,
                        $"Found unexpected selected item. Actual selected index is {selectedItemIndex}.");
                }
 
                counter++;
            }
        }
 
        protected async Task TestSignatureHelpWithMscorlib45Async(
            string markup,
            IEnumerable<SignatureHelpTestItem> expectedOrderedItems,
            string sourceLanguage)
        {
            var xmlString = string.Format(@"
<Workspace>
    <Project Language=""{0}"" CommonReferencesNet45=""true"">
        <Document FilePath=""SourceDocument"">
{1}
        </Document>
    </Project>
</Workspace>", sourceLanguage, SecurityElement.Escape(markup));
 
            using var testWorkspace = EditorTestWorkspace.Create(xmlString);
 
            var cursorPosition = testWorkspace.Documents.Single(d => d.Name == "SourceDocument").CursorPosition.Value;
            var documentId = testWorkspace.Documents.Where(d => d.Name == "SourceDocument").Single().Id;
            var document = testWorkspace.CurrentSolution.GetDocument(documentId);
            var code = (await document.GetTextAsync()).ToString();
            var options = new MemberDisplayOptions();
 
            IList<TextSpan> textSpans = null;
 
            var selectedSpans = testWorkspace.Documents.Single(d => d.Name == "SourceDocument").SelectedSpans;
            if (selectedSpans.Any())
            {
                textSpans = selectedSpans;
            }
 
            TextSpan? textSpan = null;
            if (textSpans != null && textSpans.Any())
            {
                textSpan = textSpans.First();
            }
 
            await TestSignatureHelpWorkerSharedAsync(testWorkspace, code, cursorPosition, document, options, textSpan, expectedOrderedItems);
        }
    }
}