File: IntelliSense\AsyncCompletion\ItemManager.CompletionListUpdater.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// 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.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Threading;
using Roslyn.Utilities;
using RoslynCompletionItem = Microsoft.CodeAnalysis.Completion.CompletionItem;
using VSCompletionItem = Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data.CompletionItem;
 
namespace Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense.AsyncCompletion;
 
internal partial class ItemManager
{
    /// <summary>
    /// Handles the filtering, sorting and selection of the completion items based on user inputs 
    /// (e.g. typed characters, selected filters, etc.)
    /// </summary>
    private sealed class CompletionListUpdater
    {
        // Index used for selecting suggestion item when in suggestion mode.
        private const int SuggestionItemIndex = -1;
 
        private readonly CompletionSessionData _sessionData;
        private readonly AsyncCompletionSessionDataSnapshot _snapshotData;
        private readonly RecentItemsManager _recentItemsManager;
 
        private readonly ITrackingSpan _applicableToSpan;
        private readonly bool _hasSuggestedItemOptions;
        private readonly string _filterText;
        private readonly Document? _document;
        private readonly CompletionService? _completionService;
        private readonly CompletionRules _completionRules;
        private readonly CompletionHelper _completionHelper;
        private readonly bool _highlightMatchingPortions;
        private readonly bool _showCompletionItemFilters;
 
        // Used for  building MatchResult list in parallel
        private readonly object _gate = new();
 
        private readonly Action<IReadOnlyList<MatchResult>, string, IList<MatchResult>> _filterMethod;
 
        private bool ShouldSelectSuggestionItemWhenNoItemMatchesFilterText
            => _snapshotData.DisplaySuggestionItem && _filterText.Length > 0;
 
        private CompletionTriggerReason InitialTriggerReason => _snapshotData.InitialTrigger.Reason;
        private CompletionTriggerReason UpdateTriggerReason => _snapshotData.Trigger.Reason;
 
        // We might need to handle large amount of items with import completion enabled, so use a dedicated pool to minimize/avoid array allocations
        // (especially in LOH). In practice, the size of pool should be 1 because we don't expect UpdateCompletionListAsync to be called concurrently,
        // which essentially makes the pooled list a singleton, but we still use ObjectPool for concurrency handling just to be robust.
        private static readonly ObjectPool<List<MatchResult>> s_listOfMatchResultPool = new(factory: () => []);
 
        public CompletionListUpdater(
            ITrackingSpan applicableToSpan,
            CompletionSessionData sessionData,
            AsyncCompletionSessionDataSnapshot snapshotData,
            RecentItemsManager recentItemsManager,
            IGlobalOptionService globalOptions)
        {
            _sessionData = sessionData;
            _snapshotData = snapshotData;
            _recentItemsManager = recentItemsManager;
 
            _applicableToSpan = applicableToSpan;
            _filterText = applicableToSpan.GetText(_snapshotData.Snapshot);
 
            _hasSuggestedItemOptions = _sessionData.HasSuggestionItemOptions || _snapshotData.DisplaySuggestionItem;
 
            // We prefer using the original snapshot, which should always be available from items provided by Roslyn's CompletionSource.
            // Only use data.Snapshot in the theoretically possible but rare case when all items we are handling are from some non-Roslyn CompletionSource.
            var snapshotForDocument = TryGetInitialTriggerLocation(_snapshotData, out var initialTriggerLocation)
                ? initialTriggerLocation.Snapshot
                : _snapshotData.Snapshot;
 
            _document = snapshotForDocument?.TextBuffer.AsTextContainer().GetOpenDocumentInCurrentContext();
            if (_document != null)
            {
                _completionService = _document.GetLanguageService<CompletionService>();
                _completionRules = _completionService?.GetRules(globalOptions.GetCompletionOptions(_document.Project.Language)) ?? CompletionRules.Default;
 
                // Let us make the completion Helper used for non-Roslyn items case-sensitive.
                // We can change this if get requests from partner teams.
                _completionHelper = CompletionHelper.GetHelper(_document);
                _filterMethod = _completionService == null
                    ? ((matchResults, text, filteredItemsBuilder) => CompletionService.FilterItems(_completionHelper, matchResults, text, filteredItemsBuilder))
                    : ((matchResults, text, filteredItemsBuilder) => _completionService.FilterItems(_document, matchResults, text, filteredItemsBuilder));
 
                // Nothing to highlight if user hasn't typed anything yet.
                _highlightMatchingPortions = _filterText.Length > 0
                    && globalOptions.GetOption(CompletionViewOptionsStorage.HighlightMatchingPortionsOfCompletionListItems, _document.Project.Language);
 
                _showCompletionItemFilters = globalOptions.GetOption(CompletionViewOptionsStorage.ShowCompletionItemFilters, _document.Project.Language);
            }
            else
            {
                _completionService = null;
                _completionRules = CompletionRules.Default;
 
                // Let us make the completion Helper used for non-Roslyn items case-sensitive.
                // We can change this if get requests from partner teams.
                _completionHelper = new CompletionHelper(isCaseSensitive: true);
                _filterMethod = (matchResults, text, filteredMatchResultsBuilder) => CompletionService.FilterItems(_completionHelper, matchResults, text, filteredMatchResultsBuilder);
 
                _highlightMatchingPortions = false;
                _showCompletionItemFilters = true;
            }
        }
 
        public async Task<FilteredCompletionModel?> UpdateCompletionListAsync(IAsyncCompletionSession session, CancellationToken cancellationToken)
        {
            if (ShouldDismissCompletionListImmediately())
                return null;
 
            // Use a dedicated pool to minimize potentially repeated large allocations,
            // since the completion list could be long with import completion enabled.
            var itemsToBeIncluded = s_listOfMatchResultPool.Allocate();
            var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            var threadLocalPatternMatchHelper = new ThreadLocal<PatternMatchHelper>(() => new PatternMatchHelper(_filterText), trackAllValues: true);
 
            try
            {
                // Determine the list of items to be included in the completion list.
                // This is computed based on the filter text as well as the current
                // selection of filters and expander.
                await AddCompletionItemsAsync(itemsToBeIncluded, threadLocalPatternMatchHelper, cancellationToken).ConfigureAwait(false);
 
                // Decide if we want to dismiss an empty completion list based on CompletionRules and filter usage.
                if (itemsToBeIncluded.Count == 0)
                    return HandleAllItemsFilteredOut();
 
                // Sort items based on pattern matching result
                itemsToBeIncluded.Sort(MatchResult.SortingComparer);
 
                var highlightAndFilterTask = Task.Run(
                    () => GetHighlightedListAndUpdatedFilters(session, itemsToBeIncluded, threadLocalPatternMatchHelper, cancellationTokenSource.Token),
                    cancellationTokenSource.Token);
 
                // Decide the item to be selected for this completion session.
                // The selection is mostly based on how well the item matches with the filter text, but we also need to
                // take into consideration for things like CompletionTrigger, MatchPriority, MRU, etc. 
                var initialSelection = InitialTriggerReason == CompletionTriggerReason.Backspace || InitialTriggerReason == CompletionTriggerReason.Deletion
                    ? HandleDeletionTrigger(itemsToBeIncluded, cancellationToken)
                    : HandleNormalFiltering(itemsToBeIncluded, cancellationToken);
 
                if (!initialSelection.HasValue)
                    return null;
 
                // Editor might provide a list of items to us as a suggestion to what to select for this session
                // (via IAsyncCompletionDefaultsSource), where the "default" means the "default selection".
                // The main scenario for this is to keep the selected item in completion list in sync with the
                // suggestion of "Whole-Line Completion" feature, where the default is usually set to the first token
                // of the WLC suggestion.
                var finalSelection = UpdateSelectionBasedOnSuggestedDefaults(itemsToBeIncluded, initialSelection.Value, cancellationToken);
                var (highlightedList, updatedFilters) = await highlightAndFilterTask.ConfigureAwait(false);
 
                return new FilteredCompletionModel(
                    items: highlightedList,
                    finalSelection.SelectedItemIndex,
                    filters: updatedFilters,
                    finalSelection.SelectionHint,
                    centerSelection: true,
                    finalSelection.UniqueItem);
            }
            finally
            {
                cancellationTokenSource.Cancel();
                cancellationTokenSource.Dispose();
 
                // Don't call ClearAndFree, which resets the capacity to a default value.
                itemsToBeIncluded.Clear();
                s_listOfMatchResultPool.Free(itemsToBeIncluded);
 
                // Dispose PatternMatchers
                foreach (var helper in threadLocalPatternMatchHelper.Values)
                    helper.Dispose();
 
                threadLocalPatternMatchHelper.Dispose();
            }
 
            (CompletionList<CompletionItemWithHighlight>, ImmutableArray<CompletionFilterWithState>) GetHighlightedListAndUpdatedFilters(
                IAsyncCompletionSession session, IReadOnlyList<MatchResult> itemsToBeIncluded, ThreadLocal<PatternMatchHelper> patternMatcherHelper, CancellationToken cancellationToken)
            {
                var highLightedList = GetHighlightedList(session, patternMatcherHelper.Value!, itemsToBeIncluded, cancellationToken);
                var updatedFilters = GetUpdatedFilters(itemsToBeIncluded, cancellationToken);
                return (highLightedList, updatedFilters);
            }
        }
 
        private bool ShouldDismissCompletionListImmediately()
        {
            // Check if the user is typing a number. If so, only proceed if it's a number
            // directly after a <dot>. That's because it is actually reasonable for completion
            // to be brought up after a <dot> and for the user to want to filter completion
            // items based on a number that exists in the name of the item. However, when
            // we are not after a dot (i.e. we're being brought up after <space> is typed)
            // then we don't want to filter things. Consider the user writing:
            //
            //      dim i =<space>
            //
            // We'll bring up the completion list here (as VB has completion on <space>).
            // If the user then types '3', we don't want to match against Int32.
            if (_filterText.Length > 0 && char.IsNumber(_filterText[0]) && !IsAfterDot(_snapshotData.Snapshot, _applicableToSpan))
            {
                // Dismiss the session.
                return true;
            }
 
            // DismissIfLastCharacterDeleted should be applied only when started with Insertion, and then Deleted all characters typed.
            // This conforms with the original VS 2010 behavior.
            if (InitialTriggerReason == CompletionTriggerReason.Insertion &&
                UpdateTriggerReason == CompletionTriggerReason.Backspace &&
                _completionRules.DismissIfLastCharacterDeleted &&
                _filterText.Length == 0)
            {
                // Dismiss the session
                return true;
            }
 
            return false;
        }
 
        private async Task AddCompletionItemsAsync(List<MatchResult> list, ThreadLocal<PatternMatchHelper> threadLocalPatternMatchHelper, CancellationToken cancellationToken)
        {
            // Convert initial and update trigger reasons to corresponding Roslyn type so 
            // we can interact with Roslyn's completion system
            var roslynInitialTriggerKind = Helpers.GetRoslynTriggerKind(InitialTriggerReason);
            var roslynFilterReason = Helpers.GetFilterReason(UpdateTriggerReason);
 
            // FilterStateHelper is used to decide whether a given item should be included in the list based on the state of filter/expander buttons.
            var filterHelper = new FilterStateHelper(_snapshotData.SelectedFilters);
 
            var includedPreferredItems = new ConcurrentSet<string>();
            var includedDefaults = new ConcurrentDictionary<string, MatchResult>();
 
            // Make sure we are on threadpool thread before running PLinq query to avoid sync waiting on the special high-pri thread of async-completion.
            await TaskScheduler.Default;
 
            Enumerable.Range(0, _snapshotData.InitialSortedItemList.Count)
                .AsParallel()
                .WithCancellation(cancellationToken)
                .ForAll(CreateMatchResultAndProcessMatchingDefaults);
 
            PromoteDefaultItemsToPreferredState();
 
            void CreateMatchResultAndProcessMatchingDefaults(int index)
            {
                cancellationToken.ThrowIfCancellationRequested();
                var item = _snapshotData.InitialSortedItemList[index];
 
                // All items passed in should contain a CompletionItemData object in the property bag,
                // which is guaranteed in `ItemManager.SortCompletionListAsync`.
                if (!CompletionItemData.TryGetData(item, out var itemData))
                    throw ExceptionUtilities.Unreachable();
 
                if (filterHelper.ShouldBeFilteredOut(item))
                    return;
 
                // currentIndex is used to track the index of the VS CompletionItem in the initial sorted list to maintain a map from Roslyn item to VS item.
                // It's also used to sort the items by pattern matching results while preserving the original alphabetical order for items with
                // same pattern match score since `List<T>.Sort` isn't stable.
                if (threadLocalPatternMatchHelper.Value!.TryCreateMatchResult(itemData.RoslynItem, roslynInitialTriggerKind, roslynFilterReason,
                    _recentItemsManager.GetRecentItemIndex(itemData.RoslynItem), _highlightMatchingPortions, index, out var matchResult))
                {
                    lock (_gate)
                        list.Add(matchResult);
 
                    if (!_snapshotData.Defaults.IsEmpty)
                    {
                        // Collect all the preferred items from Pythia and regular items matched defaults provided by WLC.
                        // We will use this info next to "promote" regular default items to "preferred" state on behalf of
                        // WLC to provide a coherent IntelliCode completion experience.
                        if (matchResult.CompletionItem.IsPreferredItem())
                        {
                            includedPreferredItems.Add(matchResult.CompletionItem.FilterText);
                        }
                        else
                        {
                            if (_snapshotData.Defaults.IndexOf(matchResult.CompletionItem.FilterText) >= 0)
                            {
                                includedDefaults.TryAdd(matchResult.CompletionItem.FilterText, matchResult);
                            }
                        }
                    }
                }
            }
 
            // Go through items matched with defaults. If it doesn't have
            // a corresponding preferred items, we will add one that mimic
            // the "starred" item from Pythia.
            void PromoteDefaultItemsToPreferredState()
            {
                foreach (var includedDefault in includedDefaults.Values)
                {
                    var completionItem = includedDefault.CompletionItem;
 
                    // There a preferred item matches the same default, so no need to promote this.
                    if (includedPreferredItems.Contains(completionItem.FilterText))
                        continue;
 
                    var defaultIndex = _snapshotData.Defaults.IndexOf(completionItem.FilterText);
                    var fabricatedIndex = DefaultIndexToFabricatedOriginalSortedIndex(defaultIndex);
 
                    var promotedDefaultItemMatchResult = new MatchResult(
                        Helpers.PromoteItem(completionItem, includedDefault.IndexInOriginalSortedOrder),
                        includedDefault.ShouldBeConsideredMatchingFilterText,
                        includedDefault.PatternMatch,
                        fabricatedIndex,
                        matchedAdditionalFilterText: includedDefault.MatchedAdditionalFilterText,
                        includedDefault.RecentItemIndex);
 
                    list.Add(promotedDefaultItemMatchResult);
                }
            }
 
            // This ensures promoted items are sorted in the same relative order as in defaults list, as well as before all
            // items from initial sorted list (by giving them negative value as index). 
            // e.g. if Defaults.Length = 3, we set index of Defaults[0] to -3, Defaults[1] to -2, etc. where the last one is -1.
            // All other items have original index >= 0.
            int DefaultIndexToFabricatedOriginalSortedIndex(int i)
                => i - _snapshotData.Defaults.Length;
        }
 
        private ItemSelection? HandleNormalFiltering(IReadOnlyList<MatchResult> matchResults, CancellationToken cancellationToken)
        {
            Debug.Assert(matchResults.Count > 0);
            var filteredMatchResultsBuilder = s_listOfMatchResultPool.Allocate();
 
            try
            {
                // Not deletion.  Defer to the language to decide which item it thinks best
                // matches the text typed so far.
                _filterMethod(matchResults, _filterText, filteredMatchResultsBuilder);
 
                // Ask the language to determine which of the *matched* items it wants to select.
                int selectedItemIndex;
                VSCompletionItem? uniqueItem = null;
                MatchResult bestOrFirstMatchResult;
                if (filteredMatchResultsBuilder.Count == 0)
                {
                    // When we are in suggestion mode and there's nothing in the list matches what user has typed in any ways,
                    // we should select the SuggestionItem instead.
                    if (ShouldSelectSuggestionItemWhenNoItemMatchesFilterText)
                        return new ItemSelection(SelectedItemIndex: SuggestionItemIndex, SelectionHint: UpdateSelectionHint.SoftSelected, UniqueItem: null);
 
                    // We do not have matches: pick the one with longest common prefix.
                    // If we can't find such an item, just return the first item from the list.
                    selectedItemIndex = 0;
                    bestOrFirstMatchResult = matchResults[0];
 
                    var longestCommonPrefixLength = bestOrFirstMatchResult.FilterTextUsed.GetCaseInsensitivePrefixLength(_filterText);
 
                    for (var i = 1; i < matchResults.Count; ++i)
                    {
                        var matchResult = matchResults[i];
                        var commonPrefixLength = matchResult.FilterTextUsed.GetCaseInsensitivePrefixLength(_filterText);
 
                        if (commonPrefixLength > longestCommonPrefixLength)
                        {
                            selectedItemIndex = i;
                            bestOrFirstMatchResult = matchResult;
                            longestCommonPrefixLength = commonPrefixLength;
                        }
                    }
                }
                else
                {
                    // Of the items the service returned, pick the one most recently committed
                    var bestResult = GetBestCompletionItemSelectionFromFilteredResults(filteredMatchResultsBuilder);
 
                    // Determine if we should consider this item 'unique' or not.  A unique item
                    // will be automatically committed if the user hits the 'invoke completion' 
                    // without bringing up the completion list.  An item is unique if it was the
                    // only item to match the text typed so far, and there was at least some text
                    // typed.  i.e.  if we have "Console.$$" we don't want to commit something
                    // like "WriteLine" since no filter text has actually been provided.  However,
                    // if "Console.WriteL$$" is typed, then we do want "WriteLine" to be committed.
                    for (selectedItemIndex = 0; selectedItemIndex < matchResults.Count; ++selectedItemIndex)
                    {
                        if (Equals(matchResults[selectedItemIndex].CompletionItem, bestResult.CompletionItem))
                            break;
                    }
 
                    Debug.Assert(selectedItemIndex < matchResults.Count);
 
                    bestOrFirstMatchResult = matchResults[selectedItemIndex];
 
                    if (_filterText.Length > 0)
                    {
                        // PreferredItems from IntelliCode are duplicate of normal items, so we ignore them
                        // when deciding if we have an unique item.
                        if (matchResults.Count(matchResult => matchResult.ShouldBeConsideredMatchingFilterText && !matchResult.CompletionItem.IsPreferredItem()) == 1)
                            uniqueItem = GetCorrespondingVsCompletionItem(matchResults[selectedItemIndex], cancellationToken);
                    }
                }
 
                var typedChar = _snapshotData.Trigger.Character;
 
                // Check that it is a filter symbol. We can be called for a non-filter symbol.
                // If inserting a non-filter character (neither IsPotentialFilterCharacter, nor Helpers.IsFilterCharacter),
                // we should dismiss completion except cases where this is the first symbol typed for the completion session
                // (string.IsNullOrEmpty(filterText) or string.Equals(filterText, typeChar.ToString(), StringComparison.OrdinalIgnoreCase)).
                // In the latter case, we should keep the completion because it was confirmed just before in InitializeCompletion.
                if (UpdateTriggerReason == CompletionTriggerReason.Insertion &&
                    !string.IsNullOrEmpty(_filterText) &&
                    !string.Equals(_filterText, typedChar.ToString(), StringComparison.OrdinalIgnoreCase) &&
                    !IsPotentialFilterCharacter(typedChar) &&
                    !Helpers.IsFilterCharacter(bestOrFirstMatchResult.CompletionItem, typedChar, _filterText))
                {
                    return null;
                }
 
                var isHardSelection = IsHardSelection(bestOrFirstMatchResult.CompletionItem, bestOrFirstMatchResult.ShouldBeConsideredMatchingFilterText);
                var updateSelectionHint = isHardSelection ? UpdateSelectionHint.Selected : UpdateSelectionHint.SoftSelected;
 
                return new(selectedItemIndex, updateSelectionHint, uniqueItem);
            }
            finally
            {
                // Don't call ClearAndFree, which resets the capacity to a default value.
                filteredMatchResultsBuilder.Clear();
                s_listOfMatchResultPool.Free(filteredMatchResultsBuilder);
            }
        }
 
        private VSCompletionItem GetCorrespondingVsCompletionItem(MatchResult matchResult, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            if (matchResult.IndexInOriginalSortedOrder >= 0)
                return _snapshotData.InitialSortedItemList[matchResult.IndexInOriginalSortedOrder];
 
            // This is a "promoted" item added by us, so there's no corresponding VS item from initial sorted list.
            // We need to craft one based on the data of the original item.
            var item = matchResult.CompletionItem;
            if (Helpers.TryGetOriginalIndexOfPromotedItem(item, out var unpromotedIndex))
            {
                var unpromotedVsItem = _snapshotData.InitialSortedItemList[unpromotedIndex];
                var promotedVsItem = new VSCompletionItem(
                    displayText: item.GetEntireDisplayText(),
                    unpromotedVsItem.Source,
                    unpromotedVsItem.Icon,
                    unpromotedVsItem.Filters,
                    unpromotedVsItem.Suffix,
                    unpromotedVsItem.InsertText,
                    unpromotedVsItem.SortText,
                    unpromotedVsItem.FilterText,
                    unpromotedVsItem.AutomationText,
                    unpromotedVsItem.AttributeIcons);
 
                // This is guaranteed to return true here
                CompletionItemData.TryGetData(unpromotedVsItem, out var data);
                CompletionItemData.AddData(promotedVsItem, item, data.TriggerLocation);
 
                return promotedVsItem;
            }
 
            throw ExceptionUtilities.Unreachable();
        }
 
        private ItemSelection? HandleDeletionTrigger(IReadOnlyList<MatchResult> items, CancellationToken cancellationToken)
        {
            // Go through the entire item list to find the best match(es).
            // If we had matching items, then pick the best of the matching items and
            // choose that one to be hard selected.  If we had no actual matching items
            // (which can happen if the user deletes down to a single character and we
            // include everything), then we just soft select the first item.
            var indexToSelect = 0;
            var hardSelect = false;
            MatchResult? bestMatchResult = null;
            var moreThanOneMatch = false;
 
            for (var i = 0; i < items.Count; ++i)
            {
                var currentMatchResult = items[i];
 
                if (!currentMatchResult.ShouldBeConsideredMatchingFilterText)
                    continue;
 
                if (bestMatchResult == null)
                {
                    // We had no best result yet, so this is now our best result.
                    bestMatchResult = currentMatchResult;
                    indexToSelect = i;
                }
                else
                {
                    var match = CompareForDeletion(currentMatchResult, bestMatchResult.Value, _filterText);
                    if (match > 0)
                    {
                        moreThanOneMatch = false;
                        bestMatchResult = currentMatchResult;
                        indexToSelect = i;
                    }
                    else if (match == 0)
                    {
                        moreThanOneMatch = true;
                    }
                }
            }
 
            if (bestMatchResult is null)
            {
                // The user has typed something, but nothing in the actual list matched what
                // they were typing.  In this case, we want to dismiss completion entirely.
                // The thought process is as follows: we aggressively brought up completion
                // to help them when they typed delete (in case they wanted to pick another
                // item).  However, they're typing something that doesn't seem to match at all
                // The completion list is just distracting at this point.
                if (UpdateTriggerReason == CompletionTriggerReason.Insertion)
                    return null;
 
                // If we are in suggestion mode and nothing matches filter text, we should soft select SuggestionItem.
                if (ShouldSelectSuggestionItemWhenNoItemMatchesFilterText)
                    indexToSelect = SuggestionItemIndex;
            }
            else
            {
                // Only hard select this result if it's a prefix match
                // We need to do this so that
                // * deleting and retyping a dot in a member access does not change the
                //   text that originally appeared before the dot
                // * deleting through a word from the end keeps that word selected
                // This also preserves the behavior the VB had through Dev12.
                hardSelect = !_hasSuggestedItemOptions && bestMatchResult.Value.FilterTextUsed.StartsWith(_filterText, StringComparison.CurrentCultureIgnoreCase);
            }
 
            // The best match we have selected is unique if `moreThanOneMatch` is false.
            return new(SelectedItemIndex: indexToSelect,
                SelectionHint: hardSelect ? UpdateSelectionHint.Selected : UpdateSelectionHint.SoftSelected,
                UniqueItem: moreThanOneMatch || !bestMatchResult.HasValue ? null : GetCorrespondingVsCompletionItem(bestMatchResult.Value, cancellationToken));
 
            static int CompareForDeletion(MatchResult x, MatchResult y, string pattern)
            {
                // Prefer the item that matches a longer prefix of the filter text.
                var comparison = x.FilterTextUsed.GetCaseInsensitivePrefixLength(pattern).CompareTo(y.FilterTextUsed.GetCaseInsensitivePrefixLength(pattern));
                if (comparison != 0)
                    return comparison;
 
                // If there are "Abc" vs "abc", we should prefer the case typed by user.
                comparison = x.FilterTextUsed.GetCaseSensitivePrefixLength(pattern).CompareTo(y.FilterTextUsed.GetCaseSensitivePrefixLength(pattern));
                if (comparison != 0)
                    return comparison;
 
                var xItem = x.CompletionItem;
                var yItem = y.CompletionItem;
 
                // If the lengths are the same, prefer the one with the higher match priority.
                // But only if it's an item that would have been hard selected.  We don't want
                // to aggressively select an item that was only going to be softly offered.
                comparison = GetPriority(xItem).CompareTo(GetPriority(yItem));
                if (comparison != 0)
                    return comparison;
 
                // Prefer Intellicode items.
                return xItem.IsPreferredItem().CompareTo(yItem.IsPreferredItem());
 
                static int GetPriority(RoslynCompletionItem item)
                    => item.Rules.SelectionBehavior == CompletionItemSelectionBehavior.HardSelection ? item.Rules.MatchPriority : MatchPriority.Default;
            }
        }
 
        private CompletionList<CompletionItemWithHighlight> GetHighlightedList(
            IAsyncCompletionSession session,
            PatternMatchHelper patternMatchers,
            IReadOnlyList<MatchResult> matchResults,
            CancellationToken cancellationToken)
        {
            return session.CreateCompletionList(matchResults.Select(matchResult =>
            {
                var vsItem = GetCorrespondingVsCompletionItem(matchResult, cancellationToken);
                var highlightedSpans = _highlightMatchingPortions
                    ? GetHighlightedSpans(matchResult, patternMatchers)
                    : [];
 
                return new CompletionItemWithHighlight(vsItem, highlightedSpans);
            }));
 
            static ImmutableArray<Span> GetHighlightedSpans(MatchResult matchResult, PatternMatchHelper patternMatchers)
            {
                if (matchResult.CompletionItem.HasDifferentFilterText || matchResult.CompletionItem.HasAdditionalFilterTexts)
                {
                    // The PatternMatch in MatchResult is calculated based on Roslyn item's FilterText, which can be used to calculate
                    // highlighted span for VSCompletion item's DisplayText w/o doing the matching again.
                    // However, if the Roslyn item's FilterText is different from its DisplayText, we need to do the match against the
                    // display text of the VS item directly to get the highlighted spans. This is done in a best effort fashion and there
                    // is no guarantee a proper match would be found for highlighting.
                    return patternMatchers.GetHighlightedSpans(matchResult.CompletionItem.GetEntireDisplayText(), CultureInfo.CurrentCulture)
                        .SelectAsArray(s => s.ToSpan());
                }
 
                var patternMatch = matchResult.PatternMatch;
                if (patternMatch.HasValue)
                {
                    // Since VS item's display text is created as Prefix + DisplayText + Suffix, 
                    // we can calculate the highlighted span by adding an offset that is the length of the Prefix.
                    return patternMatch.Value.MatchedSpans.SelectAsArray(GetOffsetSpan, matchResult.CompletionItem);
                }
 
                // If there's no match for Roslyn item's filter text which is identical to its display text,
                // then we can safely assume there'd be no matching to VS item's display text.
                return [];
            }
 
            // PERF: static local function to avoid lambda allocation on hot path
            static Span GetOffsetSpan(TextSpan span, RoslynCompletionItem item)
                => span.MoveTo(item.DisplayTextPrefix?.Length ?? 0).ToSpan();
        }
 
        private FilteredCompletionModel? HandleAllItemsFilteredOut()
        {
            if (UpdateTriggerReason == CompletionTriggerReason.Insertion)
            {
                // If the user was just typing, and the list went to empty *and* this is a 
                // language that wants to dismiss on empty, then just return a null model
                // to stop the completion session.
                if (_completionRules.DismissIfEmpty)
                {
                    return null;
                }
            }
 
            // If we are in suggestion mode then we should select the SuggestionItem instead.
            var selectedItemIndex = ShouldSelectSuggestionItemWhenNoItemMatchesFilterText ? SuggestionItemIndex : 0;
 
            // If the user has turned on some filtering states, and we filtered down to
            // nothing, then we do want the UI to show that to them.  That way the user
            // can turn off filters they don't want and get the right set of items.
 
            // If we are going to filter everything out, then just preserve the existing
            // model (and all the previously filtered items), but switch over to soft
            // selection.
            return new FilteredCompletionModel(
                items: ImmutableArray<CompletionItemWithHighlight>.Empty, selectedItemIndex,
                filters: _snapshotData.SelectedFilters, selectionHint: UpdateSelectionHint.SoftSelected, centerSelection: true, uniqueItem: null);
        }
 
        private ImmutableArray<CompletionFilterWithState> GetUpdatedFilters(IReadOnlyList<MatchResult> matchResults, CancellationToken cancellationToken)
        {
            if (!_showCompletionItemFilters)
                return [];
 
            // See which filters might be enabled based on the typed code
            using var _ = PooledHashSet<CompletionFilter>.GetInstance(out var filters);
            foreach (var item in matchResults)
            {
                cancellationToken.ThrowIfCancellationRequested();
                filters.AddRange(GetCorrespondingVsCompletionItem(item, cancellationToken).Filters);
            }
 
            // When no items are available for a given filter, it becomes unavailable.
            // Expanders always appear available as long as it's presented.
            return _snapshotData.SelectedFilters.SelectAsArray(n => n.WithAvailability(n.Filter is CompletionExpander || filters.Contains(n.Filter)));
        }
 
        /// <summary>
        /// Given multiple possible chosen completion items, pick the one using the following preferences (in order):
        ///     1. Most recently used item is our top preference
        ///     2. IntelliCode item over non-IntelliCode item
        ///     3. Higher MatchPriority
        ///     4. Match to FilterText over AdditionalFilterTexts
        /// </summary>
        private static MatchResult GetBestCompletionItemSelectionFromFilteredResults(IReadOnlyList<MatchResult> filteredMatchResults)
        {
            Debug.Assert(filteredMatchResults.Count > 0);
 
            var bestResult = filteredMatchResults[0];
            var bestResultMruIndex = bestResult.RecentItemIndex;
 
            for (int i = 1, n = filteredMatchResults.Count; i < n; i++)
            {
                var currentResult = filteredMatchResults[i];
                var currentResultMruIndex = currentResult.RecentItemIndex;
 
                // Most recently used item is our top preference.
                if (currentResultMruIndex != bestResultMruIndex)
                {
                    if (currentResultMruIndex > bestResultMruIndex)
                    {
                        bestResult = currentResult;
                        bestResultMruIndex = currentResultMruIndex;
                    }
 
                    continue;
                }
 
                // 2nd preference is IntelliCode item
                var currentIsPreferred = currentResult.CompletionItem.IsPreferredItem();
                var bestIsPreferred = bestResult.CompletionItem.IsPreferredItem();
 
                if (currentIsPreferred != bestIsPreferred)
                {
                    if (currentIsPreferred && !bestIsPreferred)
                    {
                        bestResult = currentResult;
                    }
 
                    continue;
                }
 
                // 3rd preference is higher MatchPriority
                var currentMatchPriority = currentResult.CompletionItem.Rules.MatchPriority;
                var bestMatchPriority = bestResult.CompletionItem.Rules.MatchPriority;
 
                if (currentMatchPriority != bestMatchPriority)
                {
                    if (currentMatchPriority > bestMatchPriority)
                    {
                        bestResult = currentResult;
                    }
 
                    continue;
                }
 
                // final preference is match to FilterText over AdditionalFilterTexts
                if (bestResult.MatchedWithAdditionalFilterTexts && !currentResult.MatchedWithAdditionalFilterTexts)
                {
                    bestResult = currentResult;
                }
            }
 
            return bestResult;
        }
 
        private static bool TryGetInitialTriggerLocation(AsyncCompletionSessionDataSnapshot data, out SnapshotPoint initialTriggerLocation)
        {
            foreach (var item in data.InitialSortedItemList)
            {
                if (CompletionItemData.TryGetData(item, out var itemData) && itemData.TriggerLocation.HasValue)
                {
                    initialTriggerLocation = itemData.TriggerLocation.Value;
                    return true;
                }
            }
 
            initialTriggerLocation = default;
            return false;
        }
 
        private bool IsHardSelection(RoslynCompletionItem item, bool matchedFilterText)
        {
            if (_hasSuggestedItemOptions)
            {
                return false;
            }
 
            // We don't have a builder and we have a best match.  Normally this will be hard
            // selected, except for a few cases.  Specifically, if no filter text has been
            // provided, and this is not a preselect match then we will soft select it.  This
            // happens when the completion list comes up implicitly and there is something in
            // the MRU list.  In this case we do want to select it, but not with a hard
            // selection.  Otherwise you can end up with the following problem:
            //
            //  dim i as integer =<space>
            //
            // Completion will comes up after = with 'integer' selected (Because of MRU).  We do
            // not want 'space' to commit this.
 
            // If all that has been typed is punctuation, then don't hard select anything.
            // It's possible the user is just typing language punctuation and selecting
            // anything in the list will interfere.  We only allow this if the filter text
            // exactly matches something in the list already. 
            if (_filterText.Length > 0 && CompletionService.IsAllPunctuation(_filterText) && _filterText != item.DisplayText)
            {
                return false;
            }
 
            // If the user hasn't actually typed anything, then don't hard select any item.
            // The only exception to this is if the completion provider has requested the
            // item be preselected.
            if (_filterText.Length == 0)
            {
                // Item didn't want to be hard selected with no filter text.
                // So definitely soft select it.
                if (item.Rules.SelectionBehavior != CompletionItemSelectionBehavior.HardSelection)
                {
                    return false;
                }
 
                // Item did not ask to be preselected.  So definitely soft select it.
                if (item.Rules.MatchPriority == MatchPriority.Default)
                {
                    return false;
                }
            }
 
            // The user typed something, or the item asked to be preselected.  In 
            // either case, don't soft select this.
            Debug.Assert(_filterText.Length > 0 || item.Rules.MatchPriority != MatchPriority.Default);
 
            // If the user moved the caret left after they started typing, the 'best' match may not match at all
            // against the full text span that this item would be replacing.
            if (!matchedFilterText)
            {
                return false;
            }
 
            // There was either filter text, or this was a preselect match.  In either case, we
            // can hard select this.
            return true;
        }
 
        /// <summary>
        /// A potential filter character is something that can filter a completion lists and is
        /// *guaranteed* to not be a commit character.
        /// </summary>
        private static bool IsPotentialFilterCharacter(char c)
        {
            // TODO(cyrusn): Actually use the right Unicode categories here.
            return char.IsLetter(c)
                || char.IsNumber(c)
                || c == '_';
        }
 
        private ItemSelection UpdateSelectionBasedOnSuggestedDefaults(IReadOnlyList<MatchResult> items, ItemSelection itemSelection, CancellationToken cancellationToken)
        {
            // Editor doesn't provide us a list of "default" items, or we select SuggestionItem (because we are in suggestion mode and have no match in the list)
            if (_snapshotData.Defaults.IsDefaultOrEmpty || itemSelection.SelectedItemIndex == SuggestionItemIndex)
                return itemSelection;
 
            // "Preselect" is only used when we have high confidence with the selection, so don't override it;
            // unless it's a real Pythia item (i.e. not fake one from us,) in which case the default item wins.
            var selectedItem = items[itemSelection.SelectedItemIndex].CompletionItem;
            if (selectedItem.Rules.MatchPriority >= MatchPriority.Preselect)
            {
                // This is a high priority item provided by Roslyn, either a regular or preferred one promoted by us
                // (because it matches one of the defaults even though it might not be the best default) so we want to stick with this selection. 
                if (!selectedItem.IsPreferredItem() || Helpers.TryGetOriginalIndexOfPromotedItem(selectedItem, out _))
                    return itemSelection;
            }
 
            return GetDefaultsMatch(items, itemSelection, cancellationToken);
        }
 
        /// <summary>
        /// Compare the pattern matching result of the current selection with the pattern matching result of the suggested defaults (both w.r.t. the filter text.)
        /// If the suggested default is no worse than current selected item (in a case-sensitive manner,) use the suggested default. Otherwise use the original selection.
        /// For example, if user typed "C", roslyn might select "CancellationToken", but with suggested default "Console" we will end up selecting "Console" instead.
        /// </summary>
        private ItemSelection GetDefaultsMatch(IReadOnlyList<MatchResult> matches, ItemSelection initialSelection, CancellationToken cancellationToken)
        {
            // Because the items are already sorted based on pattern-matching score, try to limit the range for the items we search
            // by stopping as soon as we encountered the top item from original default list, or at the first match that is inferior than current selection.
            int inferiorItemIndex;
            if (_filterText.Length == 0)
            {
                // Without filterText, all items are equally good match (w.r.t to the empty filterText), so we have to consider all of them.
                inferiorItemIndex = matches.Count;
            }
            else
            {
                var selectedItemMatch = matches[initialSelection.SelectedItemIndex].PatternMatch;
 
                // It's possible that an item doesn't match filter text but still ended up being selected, this is because we just always keep all the
                // items in the list in some cases. For example, user brought up completion with ctrl-j or through deletion.
                // Don't bother changing the selection in such cases (since there's no match to the filter text in the list)
                if (!selectedItemMatch.HasValue)
                    return initialSelection;
 
                // Because the items are sorted based on pattern-matching score, the selectedIndex is in the middle of a range of
                // -- as far as the pattern matcher is concerned -- equivalent items (items with identical PatternMatch.Kind and IsCaseSensitive).
                // Find the last items in the range and use that to limit the items searched for from the defaults list.     
                inferiorItemIndex = initialSelection.SelectedItemIndex;
                while (++inferiorItemIndex < matches.Count)
                {
                    var itemMatch = matches[inferiorItemIndex].PatternMatch;
                    if (!itemMatch.HasValue
                        || itemMatch.Value.Kind != selectedItemMatch.Value.Kind
                        || itemMatch.Value.IsCaseSensitive != selectedItemMatch.Value.IsCaseSensitive)
                    {
                        break;
                    }
                }
            }
 
            cancellationToken.ThrowIfCancellationRequested();
 
            // The range includes all items that are as good of a match as what we initially selected (and in descending order of matching score)
            // so we just need to search for a preferred item with best default match.
            var bestPromotedItemSoFar = -1;
            var bestDefaultIndex = -1;
            for (var i = 0; i < inferiorItemIndex; ++i)
            {
                var item = matches[i].CompletionItem;
                if (item.IsPreferredItem())
                {
                    var defaultIndex = _snapshotData.Defaults.IndexOf(matches[i].CompletionItem.FilterText);
 
                    // This is not a starred item that matches default
                    if (defaultIndex < 0)
                        continue;
 
                    if (bestPromotedItemSoFar < 0 || defaultIndex <= bestDefaultIndex)
                    {
                        bestPromotedItemSoFar = i;
                        bestDefaultIndex = defaultIndex;
                    }
 
                    // We have found the top default item, no need to proceed.
                    if (bestDefaultIndex == 0)
                        break;
                }
            }
 
            // Don't change the original selection since there's no match to the defaults provided.
            if (bestPromotedItemSoFar < 0)
                return initialSelection;
 
            // If user hasn't typed anything, we'd like to soft select the default item.
            // This way, the selection won't get in the way of typing.
            var selectionHint = _filterText.Length == 0 ? UpdateSelectionHint.SoftSelected : initialSelection.SelectionHint;
            return initialSelection with { SelectedItemIndex = bestPromotedItemSoFar, SelectionHint = selectionHint };
        }
 
        private sealed class FilterStateHelper
        {
            private readonly ImmutableArray<CompletionFilterWithState> _nonExpanderFilterStates;
            private readonly ImmutableArray<CompletionFilter> _selectedNonExpanderFilters;
            private readonly ImmutableArray<CompletionFilter> _unselectedExpanders;
            private readonly bool _needToFilter;
            private readonly bool _needToFilterExpanded;
 
            public FilterStateHelper(ImmutableArray<CompletionFilterWithState> filtersWithState)
            {
                // The filter state list contains two kinds of "filters": regular filter and expander.
                // The difference between them is they have different semantics.
                // - When all filters or no filter is selected, everything should be included.
                //   But when a strict subset of filters is selected, only items corresponding to the selected filters should be included.
                // - When expander is selected, all expanded items should be included, otherwise, expanded items should be excluded.
                //   expander state has no affect on non-expanded items.
                //   For example, right now we only have one expander for items from unimported namespaces, selecting/unselecting expander would
                //   include/exclude those items from completion list, but in-scope items would be shown regardless.
                // 
                // Therefore, we need to filter if 
                // 1. a non-empty strict subset of filters are selected
                // 2. a non-empty set of expanders are unselected
                _nonExpanderFilterStates = filtersWithState.WhereAsArray(f => f.Filter is not CompletionExpander);
 
                _selectedNonExpanderFilters = _nonExpanderFilterStates.SelectAsArray(f => f.IsSelected, f => f.Filter);
                _needToFilter = _selectedNonExpanderFilters.Length > 0 && _selectedNonExpanderFilters.Length < _nonExpanderFilterStates.Length;
 
                _unselectedExpanders = filtersWithState.SelectAsArray(f => !f.IsSelected && f.Filter is CompletionExpander, f => f.Filter);
                _needToFilterExpanded = _unselectedExpanders.Length > 0;
            }
 
            public bool ShouldBeFilteredOut(VSCompletionItem item)
                => ShouldBeFilteredOutOfCompletionList(item) || ShouldBeFilteredOutOfExpandedCompletionList(item);
 
            private bool ShouldBeFilteredOutOfCompletionList(VSCompletionItem item)
                => _needToFilter && !item.Filters.Any(static (filter, self) => self._selectedNonExpanderFilters.Contains(filter), this);
 
            private bool ShouldBeFilteredOutOfExpandedCompletionList(VSCompletionItem item)
            {
                if (!_needToFilterExpanded)
                    return false;
 
                var associatedWithUnselectedExpander = false;
                foreach (var itemFilter in item.Filters)
                {
                    if (itemFilter is CompletionExpander)
                    {
                        if (!_unselectedExpanders.Contains(itemFilter))
                        {
                            // If any of the associated expander is selected, the item should be included in the expanded list.
                            return false;
                        }
 
                        associatedWithUnselectedExpander = true;
                    }
                }
 
                // at this point, the item either:
                // 1. has no expander filter, therefore should be included
                // 2. or, all associated expanders are unselected, therefore should be excluded
                return associatedWithUnselectedExpander;
            }
        }
 
        private readonly record struct ItemSelection(int SelectedItemIndex, UpdateSelectionHint SelectionHint, VSCompletionItem? UniqueItem);
    }
}