File: IntelliSense\AsyncCompletion\CompletionSource.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.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Completion.Providers;
using Microsoft.CodeAnalysis.Editor.Host;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Core.Imaging;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Adornments;
using Microsoft.VisualStudio.Text.Editor;
using Roslyn.Utilities;
using AsyncCompletionData = Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data;
using RoslynCompletionItem = Microsoft.CodeAnalysis.Completion.CompletionItem;
using VSCompletionContext = Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data.CompletionContext;
using VSCompletionItem = Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data.CompletionItem;
using VSUtilities = Microsoft.VisualStudio.Utilities;
 
namespace Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense.AsyncCompletion;
 
internal sealed class CompletionSource : IAsyncExpandingCompletionSource
{
    internal const string PotentialCommitCharacters = nameof(PotentialCommitCharacters);
    internal const string NonBlockingCompletion = nameof(NonBlockingCompletion);
 
    // Don't change this property! Editor code currently has a dependency on it.
    internal const string ExcludedCommitCharacters = nameof(ExcludedCommitCharacters);
    internal const string ExcludedCommitCharactersMap = nameof(ExcludedCommitCharactersMap);
 
    private static readonly ImmutableArray<ImageElement> s_warningImageAttributeImagesArray =
        [new ImageElement(Glyph.CompletionWarning.GetImageId(), EditorFeaturesResources.Warning_image_element)];
 
    private static readonly EditorOptionKey<bool> s_nonBlockingCompletionEditorOption = new(NonBlockingCompletion);
 
    // Use CWT to cache data needed to create VSCompletionItem, so the table would be cleared when Roslyn completion item cache is cleared.
    private static readonly ConditionalWeakTable<RoslynCompletionItem, StrongBox<VSCompletionItemData>> s_roslynItemToVsItemData = new();
 
    // Cancellation series we use to stop background task for expanded items when exclusive items are returned by core providers.
    private readonly CancellationSeries _expandedItemsTaskCancellationSeries = new();
 
    private readonly ITextView _textView;
    private readonly bool _isDebuggerTextView;
    private readonly ImmutableHashSet<string> _roles;
    private readonly Lazy<IStreamingFindUsagesPresenter> _streamingPresenter;
    private readonly IThreadingContext _threadingContext;
    private readonly VSUtilities.IUIThreadOperationExecutor _operationExecutor;
    private readonly IAsynchronousOperationListener _asyncListener;
    private readonly EditorOptionsService _editorOptionsService;
    private bool _snippetCompletionTriggeredIndirectly;
 
    internal CompletionSource(
        ITextView textView,
        Lazy<IStreamingFindUsagesPresenter> streamingPresenter,
        IThreadingContext threadingContext,
        VSUtilities.IUIThreadOperationExecutor operationExecutor,
        IAsynchronousOperationListener asyncListener,
        EditorOptionsService editorOptionsService)
    {
        _textView = textView;
        _streamingPresenter = streamingPresenter;
        _threadingContext = threadingContext;
        _operationExecutor = operationExecutor;
        _asyncListener = asyncListener;
        _editorOptionsService = editorOptionsService;
        _isDebuggerTextView = textView is IDebuggerTextView;
        _roles = [.. textView.Roles];
    }
 
    public AsyncCompletionData.CompletionStartData InitializeCompletion(
        AsyncCompletionData.CompletionTrigger trigger,
        SnapshotPoint triggerLocation,
        CancellationToken cancellationToken)
    {
        var stopwatch = SharedStopwatch.StartNew();
        try
        {
            // We take sourceText from document to get a snapshot span.
            // We would like to be sure that nobody changes buffers at the same time.
            _threadingContext.ThrowIfNotOnUIThread();
 
            if (_textView.Selection.Mode == TextSelectionMode.Box)
            {
                // No completion with multiple selection
                return AsyncCompletionData.CompletionStartData.DoesNotParticipateInCompletion;
            }
 
            var document = triggerLocation.Snapshot.GetOpenDocumentInCurrentContextWithChanges();
            if (document == null)
            {
                return AsyncCompletionData.CompletionStartData.DoesNotParticipateInCompletion;
            }
 
            var service = document.GetLanguageService<CompletionService>();
            if (service == null)
            {
                return AsyncCompletionData.CompletionStartData.DoesNotParticipateInCompletion;
            }
 
            var options = _editorOptionsService.GlobalOptions.GetCompletionOptions(document.Project.Language);
 
            // The Editor supports the option per textView.
            // There could be mixed desired behavior per textView and even per same completion session.
            // The right fix would be to send this information as a result of the method. 
            // Then, the Editor would choose the right behavior for mixed cases.
            var blockForCompletionItem = _editorOptionsService.GlobalOptions.GetOption(CompletionViewOptionsStorage.BlockForCompletionItems, service.Language);
            _textView.Options.GlobalOptions.SetOptionValue(s_nonBlockingCompletionEditorOption, !blockForCompletionItem);
 
            // In case of calls with multiple completion services for the same view (e.g. TypeScript and C#), those completion services must not be called simultaneously for the same session.
            // Therefore, in each completion session we use a list of commit character for a specific completion service and a specific content type.
            _textView.Properties[PotentialCommitCharacters] = service.GetRules(options).DefaultCommitCharacters;
 
            // Reset a flag which means a snippet triggered by ? + Tab.
            // Set it later if met the condition.
            _snippetCompletionTriggeredIndirectly = false;
 
            var sourceText = document.GetTextSynchronously(cancellationToken);
 
            return ShouldTriggerCompletion(trigger, triggerLocation, sourceText, document, service, options)
                ? new AsyncCompletionData.CompletionStartData(
                    participation: AsyncCompletionData.CompletionParticipation.ProvidesItems,
                    applicableToSpan: new SnapshotSpan(
                        triggerLocation.Snapshot,
                        service.GetDefaultCompletionListSpan(sourceText, triggerLocation.Position).ToSpan()))
                : AsyncCompletionData.CompletionStartData.DoesNotParticipateInCompletion;
        }
        finally
        {
            AsyncCompletionLogger.LogSourceInitializationTicksDataPoint(stopwatch.Elapsed);
        }
    }
 
    private bool ShouldTriggerCompletion(
        AsyncCompletionData.CompletionTrigger trigger,
        SnapshotPoint triggerLocation,
        SourceText sourceText,
        Document document,
        CompletionService completionService,
        CompletionOptions options)
    {
        //The user may be trying to invoke snippets through question-tab.
        // We may provide a completion after that.
        // Otherwise, tab should not be a completion trigger.
        if (trigger.Reason == AsyncCompletionData.CompletionTriggerReason.Insertion && trigger.Character == '\t')
        {
            return TryInvokeSnippetCompletion(triggerLocation.Snapshot.TextBuffer, triggerLocation.Position, sourceText, document.Project.Services, completionService.GetRules(options));
        }
 
        var roslynTrigger = Helpers.GetRoslynTrigger(trigger, triggerLocation);
 
        // The completion service decides that user may want a completion.
        return completionService.ShouldTriggerCompletion(
            document.Project, document.Project.Services, sourceText, triggerLocation.Position, roslynTrigger, options, document.Project.Solution.Options, _roles);
    }
 
    private bool TryInvokeSnippetCompletion(
        ITextBuffer buffer, int caretPoint, SourceText text, LanguageServices services, CompletionRules rules)
    {
        // Do not invoke snippet if the corresponding rule is not set in options.
        if (rules.SnippetsRule != SnippetsRule.IncludeAfterTypingIdentifierQuestionTab)
        {
            return false;
        }
 
        var syntaxFacts = services.GetService<ISyntaxFactsService>();
        // Snippets are included if the user types: <question><tab>
        // If at least one condition for snippets do not hold, bail out.
        if (syntaxFacts == null ||
            caretPoint < 3 ||
            text[caretPoint - 2] != '?' ||
            !QuestionMarkIsPrecededByIdentifierAndWhitespace(text, caretPoint - 2, syntaxFacts))
        {
            return false;
        }
 
        // Because <question><tab> is actually a command to bring up snippets,
        // we delete the last <question> that was typed.
        buffer.ApplyChange(new TextChange(TextSpan.FromBounds(caretPoint - 2, caretPoint), string.Empty));
 
        _snippetCompletionTriggeredIndirectly = true;
        return true;
    }
 
    public async Task<VSCompletionContext> GetCompletionContextAsync(
        IAsyncCompletionSession session,
        AsyncCompletionData.CompletionTrigger trigger,
        SnapshotPoint triggerLocation,
        SnapshotSpan applicableToSpan,
        CancellationToken cancellationToken)
    {
        var totalStopWatch = SharedStopwatch.StartNew();
        try
        {
            if (session is null)
                throw new ArgumentNullException(nameof(session));
 
            var document = triggerLocation.Snapshot.GetOpenDocumentInCurrentContextWithChanges();
            if (document == null)
                return VSCompletionContext.Empty;
 
            // The computation of completion items is divided into two tasks:
            //
            // 1. "Core" items (i.e. non-expanded) which should be included in the list regardless of the selection of expander.
            //    Right now this includes all items except those from unimported namespaces.
            //    
            // 2. Expanded items which only show in the completion list when expander is selected, or by default if the corresponding
            //    features are enabled. Right now only items from unimported namespaces are associated with expander. 
            //
            // #1 is the essence of completion so we'd always wait until its task is completed and return the results. However, because we have
            // a really tight perf budget in completion, and computing those items in #2 could be expensive especially in a large solution
            // (e.g. requires syntax/symbol indices and/or runs in OOP,) we decide to kick off the computation in parallel when completion is
            // triggered, but only include its results if:
            //
            //      (a) it's completed by the time task #1 is completed and
            //      (b) including them won't interfere with users' ability to browse the list (e.g. when the list is too long since filter text is short)
            //
            // Otherwise we don't wait on it and return items from #1 immediately. Task #2 will still be running in the background
            // (until session is dismissed/committed) and we'd check back to see if it's completed whenever we have a chance to update the completion list,
            // i.e. when user typed another character, a filter was selected, etc. If so, those items will be added as part of the refresh.
            //
            // The reason of adopting this approach is we want to minimize typing delays. There are two ways user might perceive a delay in typing.
            // First, they could see a delay between typing a character and completion list being displayed if they want to examine the items available.
            // Second, they might be typing continuously w/o paying attention to completion list, and simply expect the completion to do the "right thing"
            // when a commit char is typed (e.g. commit "cancellationToken" when typing 'can$TAB$'). However, the commit could be delayed if completion is
            // still waiting on the computation of all available items, which manifests as UI delays and in worst case timeouts in commit which results in
            // unexpected behavior (e.g. typing 'can$TAB$' results in a 250ms UI freeze and still ends up with "can" instead of "cancellationToken".)
            //
            // This approach would ensure the computation of #2 will not be the cause of such delays, with the obvious trade off of potentially not providing
            // expanded items until later (or never) in a completion session even if the feature is enabled. Note that in most cases we'd expect task #2 to finish
            // in time and complete result would be available when it's most likely needed (see `ShouldHideExpandedItems` helper in ItemManager for details.)
            // However, even in the case only partial result is returned at the start, we still believe this is acceptable given how critical perf is in typing scenario.
            // Additionally, expanded items are usually considered complementary. The need for them only rise occasionally (it's rare when users need to add imports,)
            // and when they are needed, our hypothesis is because of their more intrusive nature (adding an import to the document) users would more likely to
            // contemplate such action thus typing slower before commit and/or spending more time examining the list, which give us some opportunities
            // to still provide those items later before they are truly required.
 
            var showCompletionItemFilters = _editorOptionsService.GlobalOptions.GetOption(CompletionViewOptionsStorage.ShowCompletionItemFilters, document.Project.Language);
            var options = _editorOptionsService.GlobalOptions.GetCompletionOptions(document.Project.Language) with
            {
                PerformSort = false,
                UpdateImportCompletionCacheInBackground = true,
                TargetTypedCompletionFilter = showCompletionItemFilters // Compute targeted types if filter is enabled
            };
 
            var sessionData = CompletionSessionData.GetOrCreateSessionData(session);
 
            if (!options.ShouldShowItemsFromUnimportedNamespaces)
            {
                // No need to trigger expanded providers at all if the feature is disabled, just trigger core providers and return;
                var (context, list) = await GetCompletionContextWorkerAsync(session, document, trigger, triggerLocation,
                    options with { ExpandedCompletionBehavior = ExpandedCompletionMode.NonExpandedItemsOnly }, cancellationToken).ConfigureAwait(false);
 
                UpdateSessionData(session, sessionData, list, triggerLocation);
                return context;
            }
            else
            {
                // Kicking off the task for expanded items, so it runs in parallel with regular providers.
                // Otherwise, the computation of unimported items won't start until we return those regular items to editor,
                // which combined with our behavior of not showing expanded items until ready (and only adding them during
                // completion list refresh) means increased chance that users won't see those items for the first few characters typed.
                // This does mean we might do unnecessary work if any regular provider is `exclusive`, but such cases are relatively infrequent
                // and we'd like to have expanded items available when they are needed. As these results come back potentially after
                // presentation (and sorting) of the non-expanded results, we need these results to come back already sorted.
                var expandedItemsTaskCancellationToken = _expandedItemsTaskCancellationSeries.CreateNext(cancellationToken);
                var expandedItemsTask = Task.Run(() => GetCompletionContextWorkerAsync(session, document, trigger, triggerLocation,
                                                                    options with { ExpandedCompletionBehavior = ExpandedCompletionMode.ExpandedItemsOnly, PerformSort = true },
                                                                    expandedItemsTaskCancellationToken),
                                                 expandedItemsTaskCancellationToken);
 
                // Now trigger and wait for core providers to return;
                var (nonExpandedContext, nonExpandedCompletionList) = await GetCompletionContextWorkerAsync(session, document, trigger, triggerLocation,
                        options with { ExpandedCompletionBehavior = ExpandedCompletionMode.NonExpandedItemsOnly }, cancellationToken).ConfigureAwait(false);
 
                UpdateSessionData(session, sessionData, nonExpandedCompletionList, triggerLocation);
 
                if (sessionData.IsExclusive)
                {
                    // If the core items are exclusive, we won't ever include expanded items.
                    // This would cancel expandedItemsTask.
                    _ = _expandedItemsTaskCancellationSeries.CreateNext(CancellationToken.None);
                }
                else
                {
                    sessionData.ExpandedItemsTask = expandedItemsTask;
                }
 
                AsyncCompletionLogger.LogImportCompletionGetContext();
                return nonExpandedContext;
            }
        }
        finally
        {
            AsyncCompletionLogger.LogSourceGetContextTicksDataPoint(totalStopWatch.Elapsed, isCanceled: cancellationToken.IsCancellationRequested);
        }
    }
 
    public async Task<VSCompletionContext> GetExpandedCompletionContextAsync(
        IAsyncCompletionSession session,
        AsyncCompletionData.CompletionExpander expander,
        AsyncCompletionData.CompletionTrigger initialTrigger,
        SnapshotSpan applicableToSpan,
        CancellationToken cancellationToken)
    {
        var sessionData = CompletionSessionData.GetOrCreateSessionData(session);
 
        // We only want to provide expanded items for Roslyn's expander
        if (!sessionData.IsExclusive && expander == FilterSet.Expander && sessionData.ExpandedItemTriggerLocation.HasValue)
        {
            var initialTriggerLocation = sessionData.ExpandedItemTriggerLocation.Value;
            AsyncCompletionLogger.LogExpanderUsage();
 
            // It's possible we didn't provide expanded items at the beginning of completion session because it was slow even if the feature is enabled.
            // ExpandedItemsTask would be available in this case, so we just need to return its result.
            if (sessionData.ExpandedItemsTask is not null)
            {
                // Make sure the task is removed when returning expanded items,
                // so duplicated items won't be added in subsequent list updates.
                var task = sessionData.ExpandedItemsTask;
                sessionData.ExpandedItemsTask = null;
 
                var (expandedContext, expandedCompletionList) = await task.ConfigureAwait(false);
                UpdateSessionData(session, sessionData, expandedCompletionList, initialTriggerLocation);
                return expandedContext;
            }
 
            if (sessionData.CombinedSortedList is null)
            {
                // We only reach here when expanded items are disabled, but user requested them explicitly via expander.
                // In this case, enable expanded items and trigger the completion only for them.
                var document = initialTriggerLocation.Snapshot.GetOpenDocumentInCurrentContextWithChanges();
                if (document != null)
                {
                    // User selected expander explicitly, which means we need to collect and return
                    // items from unimported namespace (and only those items) regardless of whether it's enabled.
                    var options = _editorOptionsService.GlobalOptions.GetCompletionOptions(document.Project.Language) with
                    {
                        ShowItemsFromUnimportedNamespaces = true,
                        ExpandedCompletionBehavior = ExpandedCompletionMode.ExpandedItemsOnly
                    };
 
                    var (context, completionList) = await GetCompletionContextWorkerAsync(session, document, initialTrigger, initialTriggerLocation, options, cancellationToken).ConfigureAwait(false);
                    UpdateSessionData(session, sessionData, completionList, initialTriggerLocation);
 
                    return context;
                }
            }
        }
 
        return VSCompletionContext.Empty;
    }
 
    private async Task<(VSCompletionContext, CompletionList)> GetCompletionContextWorkerAsync(
        IAsyncCompletionSession session,
        Document document,
        AsyncCompletionData.CompletionTrigger trigger,
        SnapshotPoint triggerLocation,
        CompletionOptions options,
        CancellationToken cancellationToken)
    {
        if (_isDebuggerTextView)
        {
            options = options with
            {
                FilterOutOfScopeLocals = false,
                ShowXmlDocCommentCompletion = false,
                // Adding import is not allowed in debugger view
                CanAddImportStatement = false,
            };
        }
 
        var completionService = document.GetRequiredLanguageService<CompletionService>();
        var roslynTrigger = _snippetCompletionTriggeredIndirectly
            ? new CompletionTrigger(CompletionTriggerKind.Snippets)
            : Helpers.GetRoslynTrigger(trigger, triggerLocation);
 
        var completionList = await completionService.GetCompletionsAsync(
            document, triggerLocation, options, document.Project.Solution.Options, roslynTrigger, _roles, cancellationToken).ConfigureAwait(false);
 
        var filterSet = new FilterSet(document.Project.Language is LanguageNames.CSharp or LanguageNames.VisualBasic);
        var completionItemList = session.CreateCompletionList(
            completionList.ItemsList.Select(i => Convert(document, i, filterSet, triggerLocation, cancellationToken)));
 
        var filters = filterSet.GetFilterStatesInSet();
 
        if (completionList.SuggestionModeItem is null)
            return (new(completionItemList, suggestionItemOptions: null, selectionHint: AsyncCompletionData.InitialSelectionHint.RegularSelection, filters, isIncomplete: false, null), completionList);
 
        var suggestionItemOptions = new AsyncCompletionData.SuggestionItemOptions(
            completionList.SuggestionModeItem.DisplayText,
            completionList.SuggestionModeItem.TryGetProperty(CommonCompletionItem.DescriptionProperty, out var description) ? description : string.Empty);
 
        return (new(completionItemList, suggestionItemOptions, selectionHint: AsyncCompletionData.InitialSelectionHint.SoftSelection, filters, isIncomplete: false, null), completionList);
    }
 
    private static void UpdateSessionData(IAsyncCompletionSession session, CompletionSessionData sessionData, CompletionList completionList, SnapshotPoint triggerLocation)
    {
        sessionData.IsExclusive |= completionList.IsExclusive;
 
        // Store around the span this completion list applies to.  We'll use this later
        // to pass this value in when we're committing a completion list item.
        // It's OK to overwrite this value when expanded items are requested.
        sessionData.CompletionListSpan = completionList.Span;
 
        // This is a code supporting original completion scenarios: 
        // Controller.Session_ComputeModel: if completionList.SuggestionModeItem != null, then suggestionMode = true
        // If there are suggestionItemOptions, then later HandleNormalFiltering should set selection to SoftSelection.
        sessionData.HasSuggestionItemOptions |= completionList.SuggestionModeItem != null;
 
        var excludedCommitCharactersFromList = GetExcludedCommitCharacters(completionList.ItemsList);
        if (session.Properties.TryGetProperty(ExcludedCommitCharactersMap, out MultiDictionary<char, RoslynCompletionItem> excludedCommitCharactersMap))
        {
            foreach (var kvp in excludedCommitCharactersFromList)
            {
                foreach (var item in kvp.Value)
                {
                    excludedCommitCharactersMap.Add(kvp.Key, item);
                }
            }
        }
        else
        {
            excludedCommitCharactersMap = excludedCommitCharactersFromList;
        }
 
        session.Properties[ExcludedCommitCharactersMap] = excludedCommitCharactersMap;
        session.Properties[ExcludedCommitCharacters] = excludedCommitCharactersMap.Keys.ToImmutableArray();
 
        // We need to remember the trigger location for when a completion service claims expanded items are available
        // since the initial trigger we are able to get from IAsyncCompletionSession might not be the same (e.g. in projection scenarios)
        // so when they are requested via expander later, we can retrieve it.
        // Technically we should save the trigger location for each individual service that made such claim, but in reality only Roslyn's
        // completion service uses expander, so we can get away with not making such distinction.
        if (!sessionData.ExpandedItemTriggerLocation.HasValue)
        {
            sessionData.ExpandedItemTriggerLocation = triggerLocation;
        }
    }
 
    public async Task<object?> GetDescriptionAsync(IAsyncCompletionSession session, VSCompletionItem item, CancellationToken cancellationToken)
    {
        if (session is null)
            throw new ArgumentNullException(nameof(session));
        if (item is null)
            throw new ArgumentNullException(nameof(item));
 
        if (!CompletionItemData.TryGetData(item, out var itemData) || !itemData.TriggerLocation.HasValue)
            return null;
 
        var snapshot = itemData.TriggerLocation.Value.Snapshot;
        var document = snapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
            return null;
 
        var service = document.GetLanguageService<CompletionService>();
        if (service == null)
            return null;
 
        var completionOptions = _editorOptionsService.GlobalOptions.GetCompletionOptions(document.Project.Language);
        var displayOptions = _editorOptionsService.GlobalOptions.GetSymbolDescriptionOptions(document.Project.Language);
        var description = await service.GetDescriptionAsync(document, itemData.RoslynItem, completionOptions, displayOptions, cancellationToken).ConfigureAwait(false);
        if (description == null)
            return null;
 
        var navigationActionFactory = new NavigationActionFactory(document, _threadingContext, _operationExecutor, _asyncListener, _streamingPresenter);
 
        var elements = description.TaggedParts.ToInteractiveVsTextAdornments(navigationActionFactory);
        if (elements.Length == 0)
            return new ClassifiedTextElement();
 
        if (elements.Length == 1)
            return elements[0];
 
        return new ContainerElement(ContainerElementStyle.Stacked | ContainerElementStyle.VerticalPadding, elements);
    }
 
    /// <summary>
    /// We'd like to cache VS Completion item directly to avoid allocation completely. However it holds references
    /// to transient objects, which would cause memory leak (among other potential issues) if cached. 
    /// So as a compromise,  we cache data that can be calculated from Roslyn completion item to avoid repeated 
    /// calculation cost for cached Roslyn completion items.
    /// FilterSetData is the bit vector value from the FilterSet of this item.
    /// </summary>
    private readonly record struct VSCompletionItemData(
        string DisplayText,
        ImageElement Icon,
        ImmutableArray<AsyncCompletionData.CompletionFilter> Filters,
        int FilterSetData,
        ImmutableArray<ImageElement> AttributeIcons,
        string InsertionText);
 
    private VSCompletionItem Convert(
        Document document,
        RoslynCompletionItem roslynItem,
        FilterSet filterSet,
        SnapshotPoint initialTriggerLocation,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        VSCompletionItemData itemData;
        if (roslynItem.Flags.IsCached() && s_roslynItemToVsItemData.TryGetValue(roslynItem, out var boxedItemData))
        {
            itemData = boxedItemData.Value;
            filterSet.CombineData(itemData.FilterSetData);
        }
        else
        {
            var imageId = roslynItem.Tags.GetFirstGlyph().GetImageId();
            var (filters, filterSetData) = filterSet.GetFiltersAndAddToSet(roslynItem);
 
            // roslynItem generated by providers can contain an insertionText in a property bag.
            // We will not use it but other providers may need it.
            // We actually will calculate the insertion text once again when called TryCommit.
            if (!SymbolCompletionItem.TryGetInsertionText(roslynItem, out var insertionText))
            {
                insertionText = roslynItem.DisplayText;
            }
 
            var supportedPlatforms = SymbolCompletionItem.GetSupportedPlatforms(roslynItem, document.Project.Solution);
            var attributeImages = supportedPlatforms != null ? s_warningImageAttributeImagesArray : [];
 
            itemData = new VSCompletionItemData(
                DisplayText: roslynItem.GetEntireDisplayText(),
                Icon: new ImageElement(new ImageId(imageId.Guid, imageId.Id), roslynItem.DisplayText),
                Filters: filters,
                FilterSetData: filterSetData,
                AttributeIcons: attributeImages,
                InsertionText: insertionText);
 
            // It doesn't make sense to cache VS item data for those Roslyn items created from scratch for each session,
            // since CWT uses object identity for comparison.
            if (roslynItem.Flags.IsCached())
            {
                s_roslynItemToVsItemData.Add(roslynItem, new StrongBox<VSCompletionItemData>(itemData));
            }
        }
 
        var item = new VSCompletionItem(
            displayText: itemData.DisplayText,
            source: this,
            icon: itemData.Icon,
            filters: itemData.Filters,
            suffix: roslynItem.InlineDescription, // InlineDescription will be right-aligned in the selection popup
            insertText: itemData.InsertionText,
            sortText: roslynItem.SortText,
            filterText: roslynItem.FilterText,
            automationText: roslynItem.AutomationText ?? roslynItem.DisplayText,
            attributeIcons: itemData.AttributeIcons);
 
        CompletionItemData.AddData(item, roslynItem, initialTriggerLocation);
        return item;
    }
 
    /// <summary>
    /// Build a map from added filter characters to corresponding items.
    /// CommitManager needs this information to decide whether it should commit selected item.
    /// </summary>
    private static MultiDictionary<char, RoslynCompletionItem> GetExcludedCommitCharacters(IReadOnlyList<RoslynCompletionItem> roslynItems)
    {
        var map = new MultiDictionary<char, RoslynCompletionItem>();
        foreach (var roslynItem in roslynItems)
        {
            foreach (var rule in roslynItem.Rules.FilterCharacterRules)
            {
                if (rule.Kind == CharacterSetModificationKind.Add)
                {
                    foreach (var c in rule.Characters)
                    {
                        map.Add(c, roslynItem);
                    }
                }
            }
        }
 
        return map;
    }
 
    internal static bool QuestionMarkIsPrecededByIdentifierAndWhitespace(
        SourceText text, int questionPosition, ISyntaxFactsService syntaxFacts)
    {
        var startOfLine = text.Lines.GetLineFromPosition(questionPosition).Start;
 
        // First, skip all the whitespace.
        var current = startOfLine;
        while (current < questionPosition && char.IsWhiteSpace(text[current]))
        {
            current++;
        }
 
        if (current < questionPosition && syntaxFacts.IsIdentifierStartCharacter(text[current]))
        {
            current++;
        }
        else
        {
            return false;
        }
 
        while (current < questionPosition && syntaxFacts.IsIdentifierPartCharacter(text[current]))
        {
            current++;
        }
 
        return current == questionPosition;
    }
}