File: Handler\Completion\CompletionHandler.cs
Web Access
Project: src\src\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;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
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 = Roslyn.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler
{
    /// <summary>
    /// Handle a completion request.
    /// </summary>
    [ExportCSharpVisualBasicStatelessLspService(typeof(CompletionHandler)), Shared]
    [Method(LSP.Methods.TextDocumentCompletionName)]
    internal sealed partial class CompletionHandler : ILspServiceDocumentRequestHandler<LSP.CompletionParams, LSP.CompletionList?>
    {
        private readonly IGlobalOptionService _globalOptions;
 
        public bool MutatesSolutionState => false;
        public bool RequiresLSPSolution => true;
 
        [ImportingConstructor]
        [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
        public CompletionHandler(
            IGlobalOptionService globalOptions)
        {
            _globalOptions = globalOptions;
        }
 
        public LSP.TextDocumentIdentifier GetTextDocumentIdentifier(LSP.CompletionParams request) => request.TextDocument;
 
        public async Task<LSP.CompletionList?> HandleRequestAsync(
            LSP.CompletionParams request, RequestContext context, CancellationToken cancellationToken)
        {
            Contract.ThrowIfNull(context.Document);
            Contract.ThrowIfNull(context.Solution);
 
            var document = context.Document;
            var position = await document
                .GetPositionFromLinePositionAsync(ProtocolConversions.PositionToLinePosition(request.Position), cancellationToken)
                .ConfigureAwait(false);
 
            var capabilityHelper = new CompletionCapabilityHelper(context.GetRequiredClientCapabilities());
            var completionListCache = context.GetRequiredLspService<CompletionListCache>();
 
            return await GetCompletionListAsync(
                document,
                position,
                request.Context,
                _globalOptions,
                capabilityHelper,
                completionListCache,
                cancellationToken).ConfigureAwait(false);
        }
 
        public static async Task<LSP.CompletionList?> GetCompletionListAsync(
            Document document,
            int position,
            LSP.CompletionContext? completionContext,
            IGlobalOptionService globalOptions,
            CompletionCapabilityHelper capabilityHelper,
            CompletionListCache completionListCache,
            CancellationToken cancellationToken)
        {
            var completionOptions = globalOptions.GetCompletionOptionsForLsp(document.Project.Language, capabilityHelper);
            var completionListMaxSize = globalOptions.GetOption(LspOptionsStorage.MaxCompletionListSize);
 
            var documentText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
            var completionTrigger = await ProtocolConversions
                .LSPToRoslynCompletionTriggerAsync(completionContext, document, position, cancellationToken)
                .ConfigureAwait(false);
            var completionService = document.GetRequiredLanguageService<CompletionService>();
 
            var project = document.Project;
 
            // Let CompletionService decide if we should trigger completion, unless the request is for incomplete results, in which case we always trigger. 
            if (completionContext?.TriggerKind is not LSP.CompletionTriggerKind.TriggerForIncompleteCompletions
                && !completionService.ShouldTriggerCompletion(project, project.Services, documentText, position, completionTrigger, completionOptions, project.Solution.Options, roles: null))
            {
                return null;
            }
 
            var completionListResult = await GetFilteredCompletionListAsync(
                completionContext, document, documentText, position, completionOptions, capabilityHelper, completionService, completionListCache, completionListMaxSize, cancellationToken).ConfigureAwait(false);
 
            if (completionListResult == null)
                return null;
 
            var (list, isIncomplete, isHardSelection, resultId) = completionListResult.Value;
 
            var result = await CompletionResultFactory
                .ConvertToLspCompletionListAsync(document, capabilityHelper, list, isIncomplete, isHardSelection, resultId, cancellationToken)
                .ConfigureAwait(false);
 
            return result;
        }
 
        private static async Task<(CompletionList CompletionList, bool IsIncomplete, bool isHardSelection, long ResultId)?> GetFilteredCompletionListAsync(
            LSP.CompletionContext? context,
            Document document,
            SourceText sourceText,
            int position,
            CompletionOptions completionOptions,
            CompletionCapabilityHelper capabilityHelper,
            CompletionService completionService,
            CompletionListCache completionListCache,
            int completionListMaxSize,
            CancellationToken cancellationToken)
        {
            var completionTrigger = await ProtocolConversions.LSPToRoslynCompletionTriggerAsync(context, document, position, cancellationToken).ConfigureAwait(false);
            var isTriggerForIncompleteCompletions = context?.TriggerKind == LSP.CompletionTriggerKind.TriggerForIncompleteCompletions;
 
            (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 = CompletionTrigger.Invoke;
                result = await CalculateListAsync(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(document, position, completionTrigger, completionOptions, completionService, completionListCache, cancellationToken).ConfigureAwait(false);
            }
 
            if (result == null)
            {
                return null;
            }
 
            var (completionList, resultId) = result.Value;
 
            // By default, Roslyn would treat continuous alphabetical text as a single word for completion purpose.
            // e.g. when user triggers completion at the location of {$} in "pub{$}class", the span would cover "pubclass",
            // which is used for subsequent matching and commit.
            // This works fine for VS async-completion, where we have full control of entire completion process.
            // However, the insert mode in VSCode (i.e. the mode our LSP server supports) expects us to return TextEdit that only
            // covers the span ends at the cursor location, e.g. "pub" in the example above. Here we detect when that occurs and
            // adjust the span accordingly.
            if (!capabilityHelper.SupportVSInternalClientCapabilities && position < completionList.Span.End)
            {
                var defaultSpan = new TextSpan(completionList.Span.Start, length: position - completionList.Span.Start);
                completionList = completionList.WithSpan(defaultSpan);
            }
 
            var (filteredCompletionList, isIncomplete, isHardSelection) = FilterCompletionList(completionList, completionListMaxSize, completionTrigger, sourceText, capabilityHelper);
 
            return (filteredCompletionList, isIncomplete, isHardSelection, resultId);
        }
 
        private static async Task<(CompletionList CompletionList, long ResultId)?> CalculateListAsync(
            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(completionList));
 
            return (completionList, resultId);
        }
 
        private static (CompletionList CompletionList, bool IsIncomplete, bool isHardSelection) FilterCompletionList(
            CompletionList completionList,
            int completionListMaxSize,
            CompletionTrigger completionTrigger,
            SourceText sourceText,
            CompletionCapabilityHelper completionCapabilityHelper)
        {
            var filterText = sourceText.GetSubText(completionList.Span).ToString();
            var filterReason = GetFilterReason(completionTrigger);
 
            // Determine if the list should be hard selected or soft selected.
            var isFilterTextAllPunctuation = CompletionService.IsAllPunctuation(filterText);
 
            // If we only had punctuation - we set soft selection and the list to be incomplete so we get called back when the user continues typing.
            // If they type something that is not punctuation, we may need to update the hard vs soft selection.
            // For example, typing '_' should initially be soft selection, but if the user types 'o' we should hard select '_otherVar' (if it exists).
            // This isn't perfect - ideally we would make this determination every time a filter character is typed, but we do not get called back
            // for typing filter characters in LSP (unless we always set isIncomplete, which is expensive).
            var isHardSelection = completionList.SuggestionModeItem is null && !isFilterTextAllPunctuation;
            var isIncomplete = isFilterTextAllPunctuation;
 
            // If our completion list hasn't hit the max size, we don't need to do anything filtering
            if (completionListMaxSize < 0 || completionListMaxSize >= completionList.ItemsList.Count)
                return (completionList, isIncomplete, isHardSelection);
 
            // 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,
                    filterReason,
                    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 => match.CompletionItem.Rules.MatchPriority == MatchPriority.Preselect))
                .Select(matchResult => matchResult.CompletionItem)
                .ToImmutableArray();
            var newCompletionList = completionList.WithItemsList(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
            isIncomplete |= completionCapabilityHelper.SupportVSInternalClientCapabilities
                ? completionList.ItemsList.Count > newCompletionList.ItemsList.Count
                : matchResultsBuilder.Count > filteredList.Length;
 
            return (newCompletionList, isIncomplete, isHardSelection);
 
            static CompletionFilterReason GetFilterReason(CompletionTrigger trigger)
            {
                return trigger.Kind switch
                {
                    CompletionTriggerKind.Insertion => CompletionFilterReason.Insertion,
                    CompletionTriggerKind.Deletion => CompletionFilterReason.Deletion,
                    _ => CompletionFilterReason.Other,
                };
            }
        }
    }
}