File: IntelliSense\AsyncCompletion\Helpers.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.Diagnostics;
using System.Linq;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data;
using Microsoft.VisualStudio.Text;
using EditorAsyncCompletionData = Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data;
using RoslynCompletionItem = Microsoft.CodeAnalysis.Completion.CompletionItem;
using RoslynTrigger = Microsoft.CodeAnalysis.Completion.CompletionTrigger;
 
namespace Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense.AsyncCompletion;
 
internal static class Helpers
{
    private const string PromotedItemOriginalIndexPropertyName = nameof(PromotedItemOriginalIndexPropertyName);
 
    /// <summary>
    /// Add star to display text and store the index of the passed-in item in the original sorted list in
    /// <see cref="AsyncCompletionSessionDataSnapshot.InitialSortedItemList"/> so we can retrieve it when needed.
    /// </summary>
    public static RoslynCompletionItem PromoteItem(RoslynCompletionItem item, int index)
    {
        return item.WithDisplayText(Completion.Utilities.UnicodeStarAndSpace + item.DisplayText)
        .AddProperty(PromotedItemOriginalIndexPropertyName, index.ToString());
    }
 
    public static RoslynCompletionItem DemoteItem(RoslynCompletionItem item)
    {
        if (!TryGetOriginalIndexOfPromotedItem(item, out _))
            return item;
 
        Debug.Assert(item.DisplayText.StartsWith(Completion.Utilities.UnicodeStarAndSpace));
        var newProperties = item.GetProperties().WhereAsArray((kvp, propName) => kvp.Key != propName, PromotedItemOriginalIndexPropertyName);
        return item
            .WithDisplayText(item.DisplayText[Completion.Utilities.UnicodeStarAndSpace.Length..])
            .WithProperties(newProperties);
    }
 
    public static bool TryGetOriginalIndexOfPromotedItem(RoslynCompletionItem item, out int originalIndex)
    {
        if (item.TryGetProperty(PromotedItemOriginalIndexPropertyName, out var indexString))
        {
            originalIndex = int.Parse(indexString);
            return true;
        }
 
        originalIndex = -1;
        return false;
    }
 
    /// <summary>
    /// Attempts to convert VS Completion trigger into Roslyn completion trigger
    /// </summary>
    /// <param name="trigger">VS completion trigger</param>
    /// <param name="triggerLocation">Character. 
    /// VS provides Backspace and Delete characters inside the trigger while Roslyn needs the char deleted by the trigger.
    /// Therefore, we provide this character separately and use it for Delete and Backspace cases only.
    /// We retrieve this character from triggerLocation.
    /// </param>
    /// <returns>Roslyn completion trigger</returns>
    public static RoslynTrigger GetRoslynTrigger(EditorAsyncCompletionData.CompletionTrigger trigger, SnapshotPoint triggerLocation)
    {
        var completionTriggerKind = GetRoslynTriggerKind(trigger.Reason);
        switch (completionTriggerKind)
        {
            case CompletionTriggerKind.Deletion:
                var snapshotBeforeEdit = trigger.ViewSnapshotBeforeTrigger;
                char characterRemoved;
                if (triggerLocation.Position >= 0 && triggerLocation.Position < snapshotBeforeEdit.Length)
                {
                    // If multiple characters were removed (selection), this finds the first character from the left. 
                    characterRemoved = snapshotBeforeEdit[triggerLocation.Position];
                }
                else
                {
                    characterRemoved = (char)0;
                }
 
                return RoslynTrigger.CreateDeletionTrigger(characterRemoved);
 
            case CompletionTriggerKind.Insertion:
                return RoslynTrigger.CreateInsertionTrigger(trigger.Character);
 
            default:
                return new RoslynTrigger(completionTriggerKind);
        }
    }
 
    public static CompletionTriggerKind GetRoslynTriggerKind(EditorAsyncCompletionData.CompletionTriggerReason triggerReason)
    {
        return triggerReason switch
        {
            EditorAsyncCompletionData.CompletionTriggerReason.InvokeAndCommitIfUnique => CompletionTriggerKind.InvokeAndCommitIfUnique,
            EditorAsyncCompletionData.CompletionTriggerReason.Insertion => CompletionTriggerKind.Insertion,
            EditorAsyncCompletionData.CompletionTriggerReason.Deletion or EditorAsyncCompletionData.CompletionTriggerReason.Backspace => CompletionTriggerKind.Deletion,
            EditorAsyncCompletionData.CompletionTriggerReason.SnippetsMode => CompletionTriggerKind.Snippets,
            _ => CompletionTriggerKind.Invoke,
        };
    }
 
    public static CompletionFilterReason GetFilterReason(EditorAsyncCompletionData.CompletionTriggerReason triggerReason)
    {
        return triggerReason switch
        {
            EditorAsyncCompletionData.CompletionTriggerReason.Insertion => CompletionFilterReason.Insertion,
            EditorAsyncCompletionData.CompletionTriggerReason.Deletion or EditorAsyncCompletionData.CompletionTriggerReason.Backspace => CompletionFilterReason.Deletion,
            _ => CompletionFilterReason.Other,
        };
    }
 
    public static bool IsFilterCharacter(RoslynCompletionItem item, char ch, string textTypedSoFar)
    {
        // Exclude standard commit character upfront because TextTypedSoFarMatchesItem can miss them on non-Windows platforms.
        if (IsStandardCommitCharacter(ch))
        {
            return false;
        }
 
        // First see if the item has any specific filter rules it wants followed.
        foreach (var rule in item.Rules.FilterCharacterRules)
        {
            switch (rule.Kind)
            {
                case CharacterSetModificationKind.Add:
                    if (rule.Characters.Contains(ch))
                    {
                        return true;
                    }
 
                    continue;
 
                case CharacterSetModificationKind.Remove:
                    if (rule.Characters.Contains(ch))
                    {
                        return false;
                    }
 
                    continue;
 
                case CharacterSetModificationKind.Replace:
                    return rule.Characters.Contains(ch);
            }
        }
 
        // general rule: if the filtering text exactly matches the start of the item then it must be a filter character
        if (TextTypedSoFarMatchesItem(item, textTypedSoFar))
        {
            return true;
        }
 
        return false;
    }
 
    public static bool TextTypedSoFarMatchesItem(RoslynCompletionItem item, string textTypedSoFar)
    {
        if (textTypedSoFar.Length > 0)
        {
            using var _ = PooledDelegates.GetPooledFunction(static (filterText, pattern) => filterText.StartsWith(pattern, StringComparison.CurrentCultureIgnoreCase), textTypedSoFar, out Func<string, bool> isPrefixMatch);
 
            // Note that StartsWith ignores \0 at the end of textTypedSoFar on VS Mac and Mono.
            return item.DisplayText.StartsWith(textTypedSoFar, StringComparison.CurrentCultureIgnoreCase) ||
                   item.HasDifferentFilterText && item.FilterText.StartsWith(textTypedSoFar, StringComparison.CurrentCultureIgnoreCase) ||
                   item.HasAdditionalFilterTexts && item.AdditionalFilterTexts.Any(isPrefixMatch);
        }
 
        return false;
    }
 
    // Tab, Enter and Null (call invoke commit) are always commit characters. 
    public static bool IsStandardCommitCharacter(char c)
        => c is '\t' or '\n' or '\0';
}