File: Completion\CompletionHelper.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// 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 Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.PatternMatching;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Tags;
 
namespace Microsoft.CodeAnalysis.Completion;
 
internal sealed class CompletionHelper(bool isCaseSensitive)
{
    private static CompletionHelper CaseSensitiveInstance { get; } = new CompletionHelper(isCaseSensitive: true);
    private static CompletionHelper CaseInsensitiveInstance { get; } = new CompletionHelper(isCaseSensitive: false);
 
    private readonly bool _isCaseSensitive = isCaseSensitive;
 
    public static CompletionHelper GetHelper(Document document)
    {
        var syntaxFacts = document.GetLanguageService<ISyntaxFactsService>();
        var caseSensitive = syntaxFacts?.IsCaseSensitive ?? true;
 
        return caseSensitive
            ? CaseSensitiveInstance
            : CaseInsensitiveInstance;
    }
 
    public int CompareMatchResults(MatchResult matchResult1, MatchResult matchResult2, bool filterTextHasNoUpperCase)
    {
        var item1 = matchResult1.CompletionItem;
        var match1 = matchResult1.PatternMatch;
 
        var item2 = matchResult2.CompletionItem;
        var match2 = matchResult2.PatternMatch;
 
        if (match1 != null && match2 != null)
        {
            var result = CompareItems(match1.Value, match2.Value, item1, item2, _isCaseSensitive, filterTextHasNoUpperCase);
            if (result != 0)
            {
                return result;
            }
        }
        else if (match1 != null)
        {
            return -1;
        }
        else if (match2 != null)
        {
            return 1;
        }
 
        var matchPriorityDiff = CompareSpecialMatchPriorityValues(item1, item2);
        if (matchPriorityDiff != 0)
        {
            return matchPriorityDiff;
        }
 
        // Prefer things with a keyword tag, if the filter texts are the same.
        if (!TagsEqual(item1, item2) && item1.FilterText == item2.FilterText)
        {
            return (!IsKeywordItem(item1)).CompareTo(!IsKeywordItem(item2));
        }
 
        return 0;
 
        static bool IsKeywordItem(CompletionItem item)
            => item.Tags.Contains(WellKnownTags.Keyword);
 
        static bool TagsEqual(CompletionItem item1, CompletionItem item2)
            => System.Linq.ImmutableArrayExtensions.SequenceEqual(item1.Tags, item2.Tags);
    }
 
    private static int CompareItems(
        PatternMatch match1,
        PatternMatch match2,
        CompletionItem item1,
        CompletionItem item2,
        bool isCaseSensitive,
        bool filterTextHasNoUpperCase)
    {
        // *Almost* always prefer non-expanded item regardless of the pattern matching result.
        // Except when all non-expanded items are worse than prefix matching and there's
        // a complete match from expanded ones. 
        //
        // For example, In the scenarios below, `NS2.Designer` would be selected over `System.Security.Cryptography.DES`
        //
        //  namespace System.Security.Cryptography
        //  {
        //      class DES {}
        //  }
        //  namespace NS2
        //  {
        //      class Designer {}
        //      class C
        //      {
        //          des$$
        //      }
        //  }
        //
        // But in this case, `System.Security.Cryptography.DES` would be selected over `NS2.MyDesigner`
        //
        //  namespace System.Security.Cryptography
        //  {
        //      class DES {}
        //  }
        //  namespace NS2
        //  {
        //      class MyDesigner {}
        //      class C
        //      {
        //          des$$
        //      }
        //  }
        //
        // This currently means items from unimported namespaces (those are the only expanded items now) 
        // are treated as "2nd tier" results, which forces users to be more explicit about selecting them.
        var expandedDiff = CompareExpandedItem(item1, match1, item2, match2);
        if (expandedDiff != 0)
        {
            return expandedDiff;
        }
 
        // Then see how the two items compare in a case insensitive fashion.  Matches that are strictly better (ignoring
        // case) should prioritize the item.  i.e. if we have a prefix match, that should always be better than a
        // substring match.
        //
        // The reason we ignore case is that it's very common for people to type expecting completion to fix up their
        // casing.  i.e. 'false' will be written with the expectation that it will get fixed by the completion list to
        // 'False'.  
        var caseInsensitiveComparison = match1.CompareTo(match2, ignoreCase: true);
        if (caseInsensitiveComparison != 0)
        {
            return caseInsensitiveComparison;
        }
 
        // Now we have two items match in case-insensitive manner,
        //
        // 1. if we are in a case-insensitive language, we'd first check if either item has the MatchPriority set to one
        //    of the two special values ("Preselect" and "Deprioritize"). If so and these two items have different
        //    MatchPriority, then we'd select the one of "Preselect", or the one that's not of "Deprioritize". Otherwise
        //    we will prefer the one matches case-sensitively. This is to make sure common items in VB like "True" and
        //    "False" are prioritized for selection when user types "t" and "f" (see
        //    https://github.com/dotnet/roslyn/issues/4892)
        //
        // 2. or similarly, if the filter text contains only lowercase letters, we want to relax our filtering standard
        //    a tiny bit to account for the scenario that users expect completion to fix the casing. This only happens
        //    if one of the item's MatchPriority is "Deprioritize". Otherwise we will always prefer the one matches
        //    case-sensitively. This is to make sure uncommon items like conversion "(short)" are not selected over
        //    `Should` when user types `sho` (see https://github.com/dotnet/roslyn/issues/55546)
 
        var specialMatchPriorityValuesDiff = 0;
        if (!isCaseSensitive)
        {
            specialMatchPriorityValuesDiff = CompareSpecialMatchPriorityValues(item1, item2);
        }
        else if (filterTextHasNoUpperCase)
        {
            specialMatchPriorityValuesDiff = CompareDeprioritization(item1, item2);
        }
 
        if (specialMatchPriorityValuesDiff != 0)
            return specialMatchPriorityValuesDiff;
 
        // At this point we have two items which we're matching in a rather similar fashion.
        // If one is a prefix of the other, prefer the prefix.  i.e. if we have 
        // "Table" and "table:=" and the user types 't' and we are in a case insensitive 
        // language, then we prefer the former.
        if (item1.GetEntireDisplayText().Length != item2.GetEntireDisplayText().Length)
        {
            var comparison = isCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
            if (item2.GetEntireDisplayText().StartsWith(item1.GetEntireDisplayText(), comparison))
            {
                return -1;
            }
            else if (item1.GetEntireDisplayText().StartsWith(item2.GetEntireDisplayText(), comparison))
            {
                return 1;
            }
        }
 
        // Now compare the matches again in a case sensitive manner.  If everything was
        // equal up to this point, we prefer the item that better matches based on case.
        return match1.CompareTo(match2, ignoreCase: false);
    }
 
    private static int CompareSpecialMatchPriorityValues(CompletionItem item1, CompletionItem item2)
    {
        if (item1.Rules.MatchPriority == item2.Rules.MatchPriority)
            return 0;
 
        var deprioritizationCompare = CompareDeprioritization(item1, item2);
        return deprioritizationCompare == 0
            ? ComparePreselection(item1, item2)
            : deprioritizationCompare;
    }
 
    /// <summary>
    ///  If 2 items differ on preselection, then item1 is better if it is preselected, otherwise it is worse.
    /// </summary>
    private static int ComparePreselection(CompletionItem item1, CompletionItem item2)
        => (item1.Rules.MatchPriority != MatchPriority.Preselect).CompareTo(item2.Rules.MatchPriority != MatchPriority.Preselect);
 
    /// <summary>
    /// If 2 items differ on depriorization, then item1 is worse if it is depriozritized, otherwise it is better.
    /// </summary>
    private static int CompareDeprioritization(CompletionItem item1, CompletionItem item2)
        => (item1.Rules.MatchPriority == MatchPriority.Deprioritize).CompareTo(item2.Rules.MatchPriority == MatchPriority.Deprioritize);
 
    private static int CompareExpandedItem(CompletionItem item1, PatternMatch match1, CompletionItem item2, PatternMatch match2)
    {
        var isItem1Expanded = item1.Flags.IsExpanded();
        var isItem2Expanded = item2.Flags.IsExpanded();
 
        // Consider them equal if both items are of the same kind (i.e. both expanded or non-expanded)
        if (isItem1Expanded == isItem2Expanded)
        {
            return 0;
        }
 
        // Now we have two items of different kind.
        // If neither item is exact match, we always prefer non-expanded one.
        // For example, `NS2.MyTask` would be selected over `NS1.Tasks` 
        //
        //  namespace NS1
        //  {
        //      class Tasks {}
        //  }
        //  namespace NS2
        //  {
        //      class MyTask {}
        //      class C
        //      {
        //          task$$
        //      }
        //  }
        if (match1.Kind != PatternMatchKind.Exact && match2.Kind != PatternMatchKind.Exact)
        {
            return isItem1Expanded ? 1 : -1;
        }
 
        // Now we have two items of different kind and at least one is exact match.
        // Prefer non-expanded item if it is prefix match or better.
        // In the scenarios below, `NS2.Designer` would be selected over `System.Security.Cryptography.DES`
        //
        //  namespace System.Security.Cryptography
        //  {
        //      class DES {}
        //  }
        //  namespace NS2
        //  {
        //      class Designer {}
        //      class C
        //      {
        //          des$$
        //      }
        //  }
        if (!isItem1Expanded && match1.Kind <= PatternMatchKind.Prefix)
        {
            return -1;
        }
 
        if (!isItem2Expanded && match2.Kind <= PatternMatchKind.Prefix)
        {
            return 1;
        }
 
        // Now we are left with an expanded item with exact match and a non-expanded item with worse than prefix match.
        // Prefer non-expanded item with exact match.
        Debug.Assert(isItem1Expanded && match1.Kind == PatternMatchKind.Exact && !isItem2Expanded && match2.Kind > PatternMatchKind.Prefix ||
                     isItem2Expanded && match2.Kind == PatternMatchKind.Exact && !isItem1Expanded && match1.Kind > PatternMatchKind.Prefix);
        return isItem1Expanded ? -1 : 1;
    }
}