File: Handler\Completion\CompletionHandler.cs
Web Access
Project: ..\..\..\src\Features\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.Generic;
using System.Collections.Immutable;
using System.Composition;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Completion.Providers;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageServer.Handler.Completion;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
using LSP = Microsoft.VisualStudio.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler
{
    /// <summary>
    /// Handle a completion request.
    ///
    /// TODO - This must be moved to the MS.CA.LanguageServer.Protocol project once the
    /// references to VS Icon types are removed.
    /// See https://github.com/dotnet/roslyn/issues/55142
    /// </summary>
    [ExportCSharpVisualBasicStatelessLspService(typeof(CompletionHandler)), Shared]
    [Method(LSP.Methods.TextDocumentCompletionName)]
    internal class CompletionHandler : ILspServiceDocumentRequestHandler<LSP.CompletionParams, LSP.CompletionList?>
    {
        internal const string EditRangeSetting = "editRange";
 
        private readonly IGlobalOptionService _globalOptions;
        private readonly ImmutableHashSet<char> _csharpTriggerCharacters;
        private readonly ImmutableHashSet<char> _vbTriggerCharacters;
 
        public bool MutatesSolutionState => false;
        public bool RequiresLSPSolution => true;
 
        [ImportingConstructor]
        [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
        public CompletionHandler(
            IGlobalOptionService globalOptions,
            [ImportMany] IEnumerable<Lazy<CompletionProvider, CompletionProviderMetadata>> completionProviders)
        {
            _globalOptions = globalOptions;
 
            _csharpTriggerCharacters = completionProviders.Where(lz => lz.Metadata.Language == LanguageNames.CSharp).SelectMany(
                lz => CommonCompletionUtilities.GetTriggerCharacters(lz.Value)).ToImmutableHashSet();
            _vbTriggerCharacters = completionProviders.Where(lz => lz.Metadata.Language == LanguageNames.VisualBasic).SelectMany(
                lz => CommonCompletionUtilities.GetTriggerCharacters(lz.Value)).ToImmutableHashSet();
        }
 
        public LSP.TextDocumentIdentifier GetTextDocumentIdentifier(LSP.CompletionParams request) => request.TextDocument;
 
        public async Task<LSP.CompletionList?> HandleRequestAsync(
            LSP.CompletionParams request, RequestContext context, CancellationToken cancellationToken)
        {
            var document = context.Document;
            Contract.ThrowIfNull(document);
            Contract.ThrowIfNull(context.Solution);
            var clientCapabilities = context.GetRequiredClientCapabilities();
 
            // C# and VB share the same LSP language server, and thus share the same default trigger characters.
            // We need to ensure the trigger character is valid in the document's language. For example, the '{'
            // character, while a trigger character in VB, is not a trigger character in C#.
            if (request.Context != null &&
                request.Context.TriggerKind == LSP.CompletionTriggerKind.TriggerCharacter &&
                !char.TryParse(request.Context.TriggerCharacter, out var triggerCharacter) &&
                !char.IsLetterOrDigit(triggerCharacter) &&
                !IsValidTriggerCharacterForDocument(document, triggerCharacter))
            {
                return null;
            }
 
            var completionOptions = GetCompletionOptions(document) with { UpdateImportCompletionCacheInBackground = true };
            var completionService = document.GetRequiredLanguageService<CompletionService>();
            var documentText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
 
            var completionListResult = await GetFilteredCompletionListAsync(request, context, documentText, document, completionOptions, completionService, cancellationToken).ConfigureAwait(false);
            if (completionListResult == null)
                return null;
 
            var (list, isIncomplete, resultId) = completionListResult.Value;
 
            if (list.ItemsList.Count == 0)
            {
                return new LSP.VSInternalCompletionList
                {
                    Items = Array.Empty<LSP.CompletionItem>(),
                    // If we have a suggestion mode item, we just need to keep the list in suggestion mode.
                    // We don't need to return the fake suggestion mode item.
                    SuggestionMode = list.SuggestionModeItem is not null,
                    IsIncomplete = isIncomplete,
                };
            }
 
            var lspVSClientCapability = clientCapabilities.HasVisualStudioLspCapability() == true;
            var snippetsSupported = clientCapabilities.TextDocument?.Completion?.CompletionItem?.SnippetSupport ?? false;
            var itemDefaultsSupported = clientCapabilities.TextDocument?.Completion?.CompletionListSetting?.ItemDefaults?.Contains(EditRangeSetting) == true;
 
            // We use the first item in the completion list as our comparison point for span
            // and range for optimization when generating the TextEdits later on.
            var completionChange = await completionService.GetChangeAsync(
                document, list.ItemsList[0], cancellationToken: cancellationToken).ConfigureAwait(false);
            var defaultSpan = completionChange.TextChange.Span;
            var defaultRange = ProtocolConversions.TextSpanToRange(defaultSpan, documentText);
 
            var supportsCompletionListData = clientCapabilities.HasCompletionListDataCapability();
            var completionResolveData = new CompletionResolveData()
            {
                ResultId = resultId,
            };
 
            var completionItemResolveData = supportsCompletionListData ? null : completionResolveData;
 
            using var _ = ArrayBuilder<LSP.CompletionItem>.GetInstance(out var lspCompletionItems);
            var commitCharactersRuleCache = new Dictionary<ImmutableArray<CharacterSetModificationRule>, string[]>(CommitCharacterArrayComparer.Instance);
 
            foreach (var item in list.ItemsList)
                lspCompletionItems.Add(await CreateLSPCompletionItemAsync(item).ConfigureAwait(false));
 
            var completionList = new LSP.VSInternalCompletionList
            {
                Items = lspCompletionItems.ToArray(),
                SuggestionMode = list.SuggestionModeItem != null,
                IsIncomplete = isIncomplete,
            };
 
            if (supportsCompletionListData)
                completionList.Data = completionResolveData;
 
            if (clientCapabilities.HasCompletionListCommitCharactersCapability())
                PromoteCommonCommitCharactersOntoList(completionList);
 
            if (itemDefaultsSupported)
            {
                completionList.ItemDefaults = new LSP.CompletionListItemDefaults
                {
                    EditRange = defaultRange,
                };
            }
 
            return new LSP.OptimizedVSCompletionList(completionList);
 
            // Local functions
            bool IsValidTriggerCharacterForDocument(Document document, char triggerCharacter)
            {
                if (document.Project.Language == LanguageNames.CSharp)
                {
                    return _csharpTriggerCharacters.Contains(triggerCharacter);
                }
                else if (document.Project.Language == LanguageNames.VisualBasic)
                {
                    return _vbTriggerCharacters.Contains(triggerCharacter);
                }
 
                // Typescript still calls into this for completion.
                // Since we don't know what their trigger characters are, just return true.
                return true;
            }
 
            async Task<LSP.CompletionItem> CreateLSPCompletionItemAsync(CompletionItem item)
            {
                var creationService = document.Project.Solution.Services.GetRequiredService<ILspCompletionResultCreationService>();
 
                // Defer to host to create the actual completion item (including potential subclasses), and add any
                // custom information.
                var lspItem = await creationService.CreateAsync(
                    document, documentText, snippetsSupported, itemDefaultsSupported, defaultSpan, item, cancellationToken).ConfigureAwait(false);
 
                // Now add data common to all hosts.
                lspItem.Data = completionItemResolveData;
                lspItem.Label = $"{item.DisplayTextPrefix}{item.DisplayText}{item.DisplayTextSuffix}";
 
                lspItem.SortText = item.SortText;
                lspItem.FilterText = item.FilterText;
 
                lspItem.Kind = GetCompletionKind(item.Tags);
                lspItem.Preselect = ShouldItemBePreselected(item);
 
                lspItem.CommitCharacters = GetCommitCharacters(item, commitCharactersRuleCache, lspVSClientCapability);
 
                return lspItem;
            }
 
            static LSP.CompletionItemKind GetCompletionKind(ImmutableArray<string> tags)
            {
                foreach (var tag in tags)
                {
                    if (ProtocolConversions.RoslynTagToCompletionItemKind.TryGetValue(tag, out var completionItemKind))
                        return completionItemKind;
                }
 
                return LSP.CompletionItemKind.Text;
            }
 
            static string[]? GetCommitCharacters(
                CompletionItem item,
                Dictionary<ImmutableArray<CharacterSetModificationRule>, string[]> currentRuleCache,
                bool supportsVSExtensions)
            {
                // VSCode does not have the concept of soft selection, the list is always hard selected.
                // In order to emulate soft selection behavior for things like argument completion, regex completion, datetime completion, etc
                // we create a completion item without any specific commit characters.  This means only tab / enter will commit.
                // VS supports soft selection, so we only do this for non-VS clients.
                if (!supportsVSExtensions && item.Rules.SelectionBehavior == CompletionItemSelectionBehavior.SoftSelection)
                    return Array.Empty<string>();
 
                var commitCharacterRules = item.Rules.CommitCharacterRules;
 
                // VS will use the default commit characters if no items are specified on the completion item.
                // However, other clients like VSCode do not support this behavior so we must specify
                // commit characters on every completion item - https://github.com/microsoft/vscode/issues/90987
                if (supportsVSExtensions && commitCharacterRules.IsEmpty)
                    return null;
 
                if (!currentRuleCache.TryGetValue(commitCharacterRules, out var cachedCommitCharacters))
                {
                    using var _ = PooledHashSet<char>.GetInstance(out var commitCharacters);
                    commitCharacters.AddAll(CompletionRules.Default.DefaultCommitCharacters);
                    foreach (var rule in commitCharacterRules)
                    {
                        switch (rule.Kind)
                        {
                            case CharacterSetModificationKind.Add:
                                commitCharacters.UnionWith(rule.Characters);
                                continue;
                            case CharacterSetModificationKind.Remove:
                                commitCharacters.ExceptWith(rule.Characters);
                                continue;
                            case CharacterSetModificationKind.Replace:
                                commitCharacters.Clear();
                                commitCharacters.AddRange(rule.Characters);
                                break;
                        }
                    }
 
                    cachedCommitCharacters = commitCharacters.Select(c => c.ToString()).ToArray();
                    currentRuleCache.Add(item.Rules.CommitCharacterRules, cachedCommitCharacters);
                }
 
                return cachedCommitCharacters;
            }
 
            static void PromoteCommonCommitCharactersOntoList(LSP.VSInternalCompletionList completionList)
            {
                if (completionList.Items.IsEmpty())
                {
                    return;
                }
 
                var defaultCommitCharacters = CompletionRules.Default.DefaultCommitCharacters.Select(c => c.ToString()).ToArray();
                var commitCharacterReferences = new Dictionary<object, int>();
                var mostUsedCount = 0;
                string[]? mostUsedCommitCharacters = null;
                for (var i = 0; i < completionList.Items.Length; i++)
                {
                    var completionItem = completionList.Items[i];
                    var commitCharacters = completionItem.CommitCharacters;
                    // The commit characters on the item are null, this means the commit characters are actually
                    // the default commit characters we passed in the initialize request.
                    commitCharacters ??= defaultCommitCharacters;
 
                    commitCharacterReferences.TryGetValue(commitCharacters, out var existingCount);
                    existingCount++;
 
                    if (existingCount > mostUsedCount)
                    {
                        // Capture the most used commit character counts so we don't need to re-iterate the array later
                        mostUsedCommitCharacters = commitCharacters;
                        mostUsedCount = existingCount;
                    }
 
                    commitCharacterReferences[commitCharacters] = existingCount;
                }
 
                Contract.ThrowIfNull(mostUsedCommitCharacters);
 
                // Promoted the most used commit characters onto the list and then remove these from child items.
                completionList.CommitCharacters = mostUsedCommitCharacters;
                for (var i = 0; i < completionList.Items.Length; i++)
                {
                    var completionItem = completionList.Items[i];
                    if (completionItem.CommitCharacters == mostUsedCommitCharacters)
                    {
                        completionItem.CommitCharacters = null;
                    }
                }
            }
        }
 
        private async Task<(CompletionList CompletionList, bool IsIncomplete, long ResultId)?> GetFilteredCompletionListAsync(
            LSP.CompletionParams request,
            RequestContext context,
            SourceText sourceText,
            Document document,
            CompletionOptions completionOptions,
            CompletionService completionService,
            CancellationToken cancellationToken)
        {
            var position = await document.GetPositionFromLinePositionAsync(ProtocolConversions.PositionToLinePosition(request.Position), cancellationToken).ConfigureAwait(false);
            var completionListSpan = completionService.GetDefaultCompletionListSpan(sourceText, position);
            var completionTrigger = await ProtocolConversions.LSPToRoslynCompletionTriggerAsync(request.Context, document, position, cancellationToken).ConfigureAwait(false);
            var isTriggerForIncompleteCompletions = request.Context?.TriggerKind == LSP.CompletionTriggerKind.TriggerForIncompleteCompletions;
            var completionListCache = context.GetRequiredLspService<CompletionListCache>();
 
            (CompletionList List, long ResultId)? result;
            if (isTriggerForIncompleteCompletions)
            {
                // We don't have access to the original trigger, but we know the completion list is already present.
                // It is safe to recompute with the invoked trigger as we will get all the items and filter down based on the current trigger.
                var originalTrigger = new CompletionTrigger(CompletionTriggerKind.Invoke);
                result = await CalculateListAsync(request, document, position, originalTrigger, completionOptions, completionService, completionListCache, cancellationToken).ConfigureAwait(false);
            }
            else
            {
                // This is a new completion request, clear out the last result Id for incomplete results.
                result = await CalculateListAsync(request, document, position, completionTrigger, completionOptions, completionService, completionListCache, cancellationToken).ConfigureAwait(false);
            }
 
            if (result == null)
            {
                return null;
            }
 
            var resultId = result.Value.ResultId;
 
            var completionListMaxSize = _globalOptions.GetOption(LspOptionsStorage.MaxCompletionListSize);
            var (completionList, isIncomplete) = FilterCompletionList(result.Value.List, completionListMaxSize, completionListSpan, completionTrigger, sourceText);
 
            return (completionList, isIncomplete, resultId);
        }
 
        private static async Task<(CompletionList CompletionList, long ResultId)?> CalculateListAsync(
            LSP.CompletionParams request,
            Document document,
            int position,
            CompletionTrigger completionTrigger,
            CompletionOptions completionOptions,
            CompletionService completionService,
            CompletionListCache completionListCache,
            CancellationToken cancellationToken)
        {
            var completionList = await completionService.GetCompletionsAsync(document, position, completionOptions, document.Project.Solution.Options, completionTrigger, cancellationToken: cancellationToken).ConfigureAwait(false);
            cancellationToken.ThrowIfCancellationRequested();
            if (completionList.ItemsList.IsEmpty())
            {
                return null;
            }
 
            // Cache the completion list so we can avoid recomputation in the resolve handler
            var resultId = completionListCache.UpdateCache(new CompletionListCache.CacheEntry(request.TextDocument, completionList));
 
            return (completionList, resultId);
        }
 
        private static (CompletionList CompletionList, bool IsIncomplete) FilterCompletionList(
            CompletionList completionList,
            int completionListMaxSize,
            TextSpan completionListSpan,
            CompletionTrigger completionTrigger,
            SourceText sourceText)
        {
            var filterText = sourceText.GetSubText(completionListSpan).ToString();
 
            // Use pattern matching to determine which items are most relevant out of the calculated items.
            using var _ = ArrayBuilder<MatchResult>.GetInstance(out var matchResultsBuilder);
            var index = 0;
            using var helper = new PatternMatchHelper(filterText);
            foreach (var item in completionList.ItemsList)
            {
                if (helper.TryCreateMatchResult(
                    item,
                    completionTrigger.Kind,
                    GetFilterReason(completionTrigger),
                    recentItemIndex: -1,
                    includeMatchSpans: false,
                    index,
                    out var matchResult))
                {
                    matchResultsBuilder.Add(matchResult);
                    index++;
                }
            }
 
            // Next, we sort the list based on the pattern matching result.
            matchResultsBuilder.Sort(MatchResult.SortingComparer);
 
            // Finally, truncate the list to 1000 items plus any preselected items that occur after the first 1000.
            var filteredList = matchResultsBuilder
                .Take(completionListMaxSize)
                .Concat(matchResultsBuilder.Skip(completionListMaxSize).Where(match => ShouldItemBePreselected(match.CompletionItem)))
                .Select(matchResult => matchResult.CompletionItem)
                .ToImmutableArray();
            var newCompletionList = completionList.WithItems(filteredList);
 
            // Per the LSP spec, the completion list should be marked with isIncomplete = false when further insertions will
            // not generate any more completion items.  This means that we should be checking if the matchedResults is larger
            // than the filteredList.  However, the VS client has a bug where they do not properly re-trigger completion
            // when a character is deleted to go from a complete list back to an incomplete list.
            // For example, the following scenario.
            // User types "So" -> server gives subset of items for "So" with isIncomplete = true
            // User types "m" -> server gives entire set of items for "Som" with isIncomplete = false
            // User deletes "m" -> client has to remember that "So" results were incomplete and re-request if the user types something else, like "n"
            //
            // Currently the VS client does not remember to re-request, so the completion list only ever shows items from "Som"
            // so we always set the isIncomplete flag to true when the original list size (computed when no filter text was typed) is too large.
            // VS bug here - https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1335142
            var isIncomplete = completionList.ItemsList.Count > newCompletionList.ItemsList.Count;
 
            return (newCompletionList, isIncomplete);
 
            static CompletionFilterReason GetFilterReason(CompletionTrigger trigger)
            {
                return trigger.Kind switch
                {
                    CompletionTriggerKind.Insertion => CompletionFilterReason.Insertion,
                    CompletionTriggerKind.Deletion => CompletionFilterReason.Deletion,
                    _ => CompletionFilterReason.Other,
                };
            }
        }
 
        public static bool ShouldItemBePreselected(CompletionItem completionItem)
        {
            // An item should be preselected for LSP when the match priority is preselect and the item is hard selected.
            // LSP does not support soft preselection, so we do not preselect in that scenario to avoid interfering with
            // typing.
            return completionItem.Rules.MatchPriority == MatchPriority.Preselect && completionItem.Rules.SelectionBehavior == CompletionItemSelectionBehavior.HardSelection;
        }
 
        internal CompletionOptions GetCompletionOptions(Document document)
        {
            // Filter out unimported types for now as there are two issues with providing them:
            // 1.  LSP client does not currently provide a way to provide detail text on the completion item to show the namespace.
            //     https://dev.azure.com/devdiv/DevDiv/_workitems/edit/1076759
            // 2.  We need to figure out how to provide the text edits along with the completion item or provide them in the resolve request.
            //     https://devdiv.visualstudio.com/DevDiv/_workitems/edit/985860/
            // 3.  LSP client should support completion filters / expanders
            return _globalOptions.GetCompletionOptions(document.Project.Language) with
            {
                ShowItemsFromUnimportedNamespaces = false,
                ExpandedCompletionBehavior = ExpandedCompletionMode.NonExpandedItemsOnly
            };
        }
 
        private class CommitCharacterArrayComparer : IEqualityComparer<ImmutableArray<CharacterSetModificationRule>>
        {
            public static readonly CommitCharacterArrayComparer Instance = new();
 
            private CommitCharacterArrayComparer()
            {
            }
 
            public bool Equals([AllowNull] ImmutableArray<CharacterSetModificationRule> x, [AllowNull] ImmutableArray<CharacterSetModificationRule> y)
            {
                if (x == y)
                    return true;
 
                for (var i = 0; i < x.Length; i++)
                {
                    var xKind = x[i].Kind;
                    var yKind = y[i].Kind;
                    if (xKind != yKind)
                    {
                        return false;
                    }
 
                    var xCharacters = x[i].Characters;
                    var yCharacters = y[i].Characters;
                    if (xCharacters.Length != yCharacters.Length)
                    {
                        return false;
                    }
 
                    for (var j = 0; j < xCharacters.Length; j++)
                    {
                        if (xCharacters[j] != yCharacters[j])
                        {
                            return false;
                        }
                    }
                }
 
                return true;
            }
 
            public int GetHashCode([DisallowNull] ImmutableArray<CharacterSetModificationRule> obj)
            {
                var combinedHash = Hash.CombineValues(obj);
                return combinedHash;
            }
        }
    }
}