// 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.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.PatternMatching;
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 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 class ItemManager : IAsyncCompletionItemManager
{
///
/// Used for filtering non-Roslyn data only.
///
private readonly CompletionHelper _defaultCompletionHelper;
private readonly RecentItemsManager _recentItemsManager;
///
/// For telemetry.
///
private readonly object _targetTypeCompletionFilterChosenMarker = new object();
internal ItemManager(RecentItemsManager recentItemsManager)
{
// Let us make the completion Helper used for non-Roslyn items case-sensitive.
// We can change this if get requests from partner teams.
_defaultCompletionHelper = new CompletionHelper(isCaseSensitive: true);
_recentItemsManager = recentItemsManager;
}
public Task> SortCompletionListAsync(
IAsyncCompletionSession session,
AsyncCompletionSessionInitialDataSnapshot data,
CancellationToken cancellationToken)
{
if (session.TextView.Properties.TryGetProperty(CompletionSource.TargetTypeFilterExperimentEnabled, out bool isTargetTypeFilterEnabled) && isTargetTypeFilterEnabled)
{
AsyncCompletionLogger.LogSessionHasTargetTypeFilterEnabled();
// This method is called exactly once, so use the opportunity to set a baseline for telemetry.
if (data.InitialList.Any(i => i.Filters.Any(f => f.DisplayText == FeaturesResources.Target_type_matches)))
{
AsyncCompletionLogger.LogSessionContainsTargetTypeFilter();
}
}
if (session.TextView.Properties.TryGetProperty(CompletionSource.TypeImportCompletionEnabled, out bool isTypeImportCompletionEnabled) && isTypeImportCompletionEnabled)
{
AsyncCompletionLogger.LogSessionWithTypeImportCompletionEnabled();
}
return Task.FromResult(data.InitialList.OrderBy(i => i.SortText, StringComparer.Ordinal).ToImmutableArray());
}
public Task UpdateCompletionListAsync(
IAsyncCompletionSession session,
AsyncCompletionSessionDataSnapshot data,
CancellationToken cancellationToken)
=> Task.FromResult(UpdateCompletionList(session, data, cancellationToken));
private FilteredCompletionModel UpdateCompletionList(
IAsyncCompletionSession session,
AsyncCompletionSessionDataSnapshot data,
CancellationToken cancellationToken)
{
if (!session.Properties.TryGetProperty(CompletionSource.HasSuggestionItemOptions, out bool hasSuggestedItemOptions))
{
// This is the scenario when the session is created out of Roslyn, in some other provider, e.g. in Debugger.
// For now, the default hasSuggestedItemOptions is false.
hasSuggestedItemOptions = false;
}
hasSuggestedItemOptions |= data.DisplaySuggestionItem;
var filterText = session.ApplicableToSpan.GetText(data.Snapshot);
var reason = data.Trigger.Reason;
var initialRoslynTriggerKind = Helpers.GetRoslynTriggerKind(data.InitialTrigger);
// Check if the user is typing a number. If so, only proceed if it's a number
// directly after a . That's because it is actually reasonable for completion
// to be brought up after a 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 is typed)
// then we don't want to filter things. Consider the user writing:
//
// dim i =
//
// We'll bring up the completion list here (as VB has completion on ).
// If the user then types '3', we don't want to match against Int32.
if (filterText.Length > 0 && char.IsNumber(filterText[0]))
{
if (!IsAfterDot(data.Snapshot, session.ApplicableToSpan))
{
// Dismiss the session.
return null;
}
}
// We need to filter if
// 1. a non-empty strict subset of filters are selected
// 2. a non-empty set of expanders are unselected
var nonExpanderFilterStates = data.SelectedFilters.WhereAsArray(f => !(f.Filter is CompletionExpander));
var selectedNonExpanderFilters = nonExpanderFilterStates.Where(f => f.IsSelected).SelectAsArray(f => f.Filter);
var needToFilter = selectedNonExpanderFilters.Length > 0 && selectedNonExpanderFilters.Length < nonExpanderFilterStates.Length;
var unselectedExpanders = data.SelectedFilters.Where(f => !f.IsSelected && f.Filter is CompletionExpander).SelectAsArray(f => f.Filter);
var needToFilterExpanded = unselectedExpanders.Length > 0;
if (session.TextView.Properties.TryGetProperty(CompletionSource.TargetTypeFilterExperimentEnabled, out bool isExperimentEnabled) && isExperimentEnabled)
{
// Telemetry: Want to know % of sessions with the "Target type matches" filter where that filter is actually enabled
if (needToFilter &&
!session.Properties.ContainsProperty(_targetTypeCompletionFilterChosenMarker) &&
selectedNonExpanderFilters.Any(f => f.DisplayText == FeaturesResources.Target_type_matches))
{
AsyncCompletionLogger.LogTargetTypeFilterChosenInSession();
// Make sure we only record one enabling of the filter per session
session.Properties.AddProperty(_targetTypeCompletionFilterChosenMarker, _targetTypeCompletionFilterChosenMarker);
}
}
var filterReason = Helpers.GetFilterReason(data.Trigger);
// If the session was created/maintained out of Roslyn, e.g. in debugger; no properties are set and we should use data.Snapshot.
// However, we prefer using the original snapshot in some projection scenarios.
var snapshotForDocument = Helpers.TryGetInitialTriggerLocation(session, out var triggerLocation)
? triggerLocation.Snapshot
: data.Snapshot;
var document = snapshotForDocument.TextBuffer.AsTextContainer().GetOpenDocumentInCurrentContext();
var completionService = document?.GetLanguageService();
var completionRules = completionService?.GetRules() ?? CompletionRules.Default;
var completionHelper = document != null ? CompletionHelper.GetHelper(document) : _defaultCompletionHelper;
// DismissIfLastCharacterDeleted should be applied only when started with Insertion, and then Deleted all characters typed.
// This conforms with the original VS 2010 behavior.
if (initialRoslynTriggerKind == CompletionTriggerKind.Insertion &&
data.Trigger.Reason == CompletionTriggerReason.Backspace &&
completionRules.DismissIfLastCharacterDeleted &&
session.ApplicableToSpan.GetText(data.Snapshot).Length == 0)
{
// Dismiss the session
return null;
}
var options = document?.Project.Solution.Options;
var highlightMatchingPortions = options?.GetOption(CompletionOptions.HighlightMatchingPortionsOfCompletionListItems, document.Project.Language) ?? false;
// Nothing to highlight if user hasn't typed anything yet.
highlightMatchingPortions = highlightMatchingPortions && filterText.Length > 0;
// Use a monotonically increasing integer to keep track the original alphabetical order of each item.
var currentIndex = 0;
var builder = ArrayBuilder.GetInstance();
foreach (var item in data.InitialSortedList)
{
cancellationToken.ThrowIfCancellationRequested();
if (needToFilter && ShouldBeFilteredOutOfCompletionList(item, selectedNonExpanderFilters))
{
continue;
}
if (needToFilterExpanded && ShouldBeFilteredOutOfExpandedCompletionList(item, unselectedExpanders))
{
continue;
}
if (TryCreateMatchResult(
completionHelper,
item,
filterText,
initialRoslynTriggerKind,
filterReason,
_recentItemsManager.RecentItems,
highlightMatchingPortions: highlightMatchingPortions,
ref currentIndex,
out var matchResult))
{
builder.Add(matchResult);
}
}
if (builder.Count == 0)
{
return HandleAllItemsFilteredOut(reason, data.SelectedFilters, completionRules);
}
// Sort the items by pattern matching results.
// Note that we want to preserve the original alphabetical order for items with same pattern match score,
// but `ArrayBuilder.Sort` isn't stable. Therefore we have to add a monotonically increasing integer
// to `MatchResult` to achieve this.
builder.Sort(MatchResult.SortingComparer);
var initialListOfItemsToBeIncluded = builder.ToImmutableAndFree();
var showCompletionItemFilters = options?.GetOption(CompletionOptions.ShowCompletionItemFilters, document.Project.Language) ?? true;
var updatedFilters = showCompletionItemFilters
? GetUpdatedFilters(initialListOfItemsToBeIncluded, data.SelectedFilters)
: ImmutableArray.Empty;
// If this was deletion, then we control the entire behavior of deletion ourselves.
if (initialRoslynTriggerKind == CompletionTriggerKind.Deletion)
{
return HandleDeletionTrigger(data.Trigger.Reason, initialListOfItemsToBeIncluded, filterText, updatedFilters);
}
Func, string, ImmutableArray> filterMethod;
if (completionService == null)
{
filterMethod = (itemsWithPatternMatches, text) => CompletionService.FilterItems(completionHelper, itemsWithPatternMatches);
}
else
{
filterMethod = (itemsWithPatternMatches, text) => completionService.FilterItems(document, itemsWithPatternMatches, text);
}
return HandleNormalFiltering(
filterMethod,
filterText,
updatedFilters,
filterReason,
data.Trigger.Character,
initialListOfItemsToBeIncluded,
hasSuggestedItemOptions);
static bool ShouldBeFilteredOutOfCompletionList(VSCompletionItem item, ImmutableArray activeNonExpanderFilters)
{
if (item.Filters.Any(filter => activeNonExpanderFilters.Contains(filter)))
{
return false;
}
return true;
}
static bool ShouldBeFilteredOutOfExpandedCompletionList(VSCompletionItem item, ImmutableArray unselectedExpanders)
{
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 static bool IsAfterDot(ITextSnapshot snapshot, ITrackingSpan applicableToSpan)
{
var position = applicableToSpan.GetStartPoint(snapshot).Position;
return position > 0 && snapshot[position - 1] == '.';
}
private FilteredCompletionModel HandleNormalFiltering(
Func, string, ImmutableArray> filterMethod,
string filterText,
ImmutableArray filters,
CompletionFilterReason filterReason,
char typeChar,
ImmutableArray itemsInList,
bool hasSuggestedItemOptions)
{
// Not deletion. Defer to the language to decide which item it thinks best
// matches the text typed so far.
// Ask the language to determine which of the *matched* items it wants to select.
var matchingItems = itemsInList.Where(r => r.MatchedFilterText)
.SelectAsArray(t => (t.RoslynCompletionItem, t.PatternMatch));
var chosenItems = filterMethod(matchingItems, filterText);
var selectedItemIndex = 0;
VSCompletionItem uniqueItem = null;
MatchResult bestOrFirstMatchResult;
if (chosenItems.Length == 0)
{
// We do not have matches: pick the first item from the list.
bestOrFirstMatchResult = itemsInList.FirstOrDefault();
}
else
{
var recentItems = _recentItemsManager.RecentItems;
// Of the items the service returned, pick the one most recently committed
var bestItem = GetBestCompletionItemBasedOnMRU(chosenItems, recentItems);
// 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.
selectedItemIndex = itemsInList.IndexOf(i => Equals(i.RoslynCompletionItem, bestItem));
bestOrFirstMatchResult = itemsInList[selectedItemIndex];
var deduplicatedListCount = matchingItems.Count(r => !r.RoslynCompletionItem.IsPreferredItem());
if (deduplicatedListCount == 1 &&
filterText.Length > 0)
{
uniqueItem = itemsInList[selectedItemIndex].VSCompletionItem;
}
}
// 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 (filterReason == CompletionFilterReason.Insertion &&
!string.IsNullOrEmpty(filterText) &&
!string.Equals(filterText, typeChar.ToString(), StringComparison.OrdinalIgnoreCase) &&
!IsPotentialFilterCharacter(typeChar) &&
!Helpers.IsFilterCharacter(bestOrFirstMatchResult.RoslynCompletionItem, typeChar, filterText))
{
return null;
}
var isHardSelection = IsHardSelection(
filterText, bestOrFirstMatchResult.RoslynCompletionItem, bestOrFirstMatchResult.MatchedFilterText, hasSuggestedItemOptions);
var updateSelectionHint = isHardSelection ? UpdateSelectionHint.Selected : UpdateSelectionHint.SoftSelected;
// If no items found above, select the first item.
if (selectedItemIndex == -1)
{
selectedItemIndex = 0;
}
return new FilteredCompletionModel(
GetHighlightedList(itemsInList), selectedItemIndex, filters,
updateSelectionHint, centerSelection: true, uniqueItem);
}
private FilteredCompletionModel HandleDeletionTrigger(
CompletionTriggerReason filterTriggerKind,
ImmutableArray matchResults,
string filterText,
ImmutableArray filters)
{
var matchingItems = matchResults.WhereAsArray(r => r.MatchedFilterText);
if (filterTriggerKind == CompletionTriggerReason.Insertion &&
!matchingItems.Any())
{
// 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.
return null;
}
MatchResult? bestMatchResult = null;
bool moreThanOneMatchWithSamePriority = false;
foreach (var currentMatchResult in matchingItems)
{
if (bestMatchResult == null)
{
// We had no best result yet, so this is now our best result.
bestMatchResult = currentMatchResult;
}
else
{
var match = currentMatchResult.CompareTo(bestMatchResult.Value, filterText);
if (match > 0)
{
moreThanOneMatchWithSamePriority = false;
bestMatchResult = currentMatchResult;
}
else if (match == 0)
{
moreThanOneMatchWithSamePriority = true;
}
}
}
int index;
bool hardSelect;
// If we had a matching item, 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.
if (bestMatchResult != null)
{
// 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 = bestMatchResult.Value.VSCompletionItem.FilterText.StartsWith(filterText, StringComparison.CurrentCultureIgnoreCase);
index = matchResults.IndexOf(bestMatchResult.Value);
}
else
{
index = 0;
hardSelect = false;
}
return new FilteredCompletionModel(
GetHighlightedList(matchResults), index, filters,
hardSelect ? UpdateSelectionHint.Selected : UpdateSelectionHint.SoftSelected,
centerSelection: true,
uniqueItem: moreThanOneMatchWithSamePriority ? default : bestMatchResult.GetValueOrDefault().VSCompletionItem);
}
private static ImmutableArray GetHighlightedList(ImmutableArray matchResults)
=> matchResults.SelectAsArray(matchResult =>
new CompletionItemWithHighlight(matchResult.VSCompletionItem, matchResult.HighlightedSpans));
private FilteredCompletionModel HandleAllItemsFilteredOut(
CompletionTriggerReason triggerReason,
ImmutableArray filters,
CompletionRules completionRules)
{
if (triggerReason == 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 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.
var selection = UpdateSelectionHint.SoftSelected;
return new FilteredCompletionModel(
ImmutableArray.Empty, selectedItemIndex: 0,
filters, selection, centerSelection: true, uniqueItem: default);
}
private static ImmutableArray GetUpdatedFilters(
ImmutableArray filteredList,
ImmutableArray filters)
{
// See which filters might be enabled based on the typed code
var textFilteredFilters = filteredList.SelectMany(n => n.VSCompletionItem.Filters).ToImmutableHashSet();
// When no items are available for a given filter, it becomes unavailable.
// Expanders always appear available as long as it's presented.
return filters.SelectAsArray(n => n.WithAvailability(n.Filter is CompletionExpander ? true : textFilteredFilters.Contains(n.Filter)));
}
///
/// Given multiple possible chosen completion items, pick the one that has the
/// best MRU index.
///
private static RoslynCompletionItem GetBestCompletionItemBasedOnMRU(
ImmutableArray chosenItems, ImmutableArray recentItems)
{
// Try to find the chosen item has been most
// recently used.
var bestItem = chosenItems.FirstOrDefault();
for (int i = 0, n = chosenItems.Length; i < n; i++)
{
var chosenItem = chosenItems[i];
var mruIndex1 = GetRecentItemIndex(recentItems, bestItem);
var mruIndex2 = GetRecentItemIndex(recentItems, chosenItem);
if ((mruIndex2 < mruIndex1) ||
(mruIndex2 == mruIndex1 && !bestItem.IsPreferredItem() && chosenItem.IsPreferredItem()))
{
bestItem = chosenItem;
}
}
// If our best item appeared in the MRU list, use it
if (GetRecentItemIndex(recentItems, bestItem) <= 0)
{
return bestItem;
}
// Otherwise use the chosen item that has the highest
// matchPriority.
for (int i = 1, n = chosenItems.Length; i < n; i++)
{
var chosenItem = chosenItems[i];
var bestItemPriority = bestItem.Rules.MatchPriority;
var currentItemPriority = chosenItem.Rules.MatchPriority;
if ((currentItemPriority > bestItemPriority) ||
((currentItemPriority == bestItemPriority) && !bestItem.IsPreferredItem() && chosenItem.IsPreferredItem()))
{
bestItem = chosenItem;
}
}
return bestItem;
}
private static int GetRecentItemIndex(ImmutableArray recentItems, RoslynCompletionItem item)
{
var index = recentItems.IndexOf(item.FilterText);
return -index;
}
private static bool TryCreateMatchResult(
CompletionHelper completionHelper,
VSCompletionItem item,
string filterText,
CompletionTriggerKind initialTriggerKind,
CompletionFilterReason filterReason,
ImmutableArray recentItems,
bool highlightMatchingPortions,
ref int currentIndex,
out MatchResult matchResult)
{
if (!item.Properties.TryGetProperty(CompletionSource.RoslynItem, out RoslynCompletionItem roslynItem))
{
roslynItem = RoslynCompletionItem.Create(
displayText: item.DisplayText,
filterText: item.FilterText,
sortText: item.SortText,
displayTextSuffix: item.Suffix);
}
// Get the match of the given completion item for the pattern provided so far.
// A completion item is checked against the pattern by see if it's
// CompletionItem.FilterText matches the item. That way, the pattern it checked
// against terms like "IList" and not IList<>.
// Note that the check on filter text length is purely for efficiency, we should
// get the same result with or without it.
var patternMatch = filterText.Length > 0
? completionHelper.GetMatch(item.FilterText, filterText, includeMatchSpans: highlightMatchingPortions, CultureInfo.CurrentCulture)
: null;
var matchedFilterText = MatchesFilterText(
roslynItem,
filterText,
initialTriggerKind,
filterReason,
recentItems,
patternMatch);
// If the item didn't match the filter text, we still keep it in the list
// if one of two things is true:
//
// 1. The user has typed nothing or only typed a single character. In this case they might
// have just typed the character to get completion. Filtering out items
// here is not desirable.
//
// 2. They brought up completion with ctrl-j or through deletion. In these
// cases we just always keep all the items in the list.
if (matchedFilterText ||
initialTriggerKind == CompletionTriggerKind.Deletion ||
initialTriggerKind == CompletionTriggerKind.Invoke ||
filterText.Length <= 1)
{
matchResult = new MatchResult(
roslynItem, item, matchedFilterText: matchedFilterText,
patternMatch: patternMatch, index: currentIndex++, GetHighlightedSpans());
return true;
}
matchResult = default;
return false;
ImmutableArray GetHighlightedSpans()
{
if (!highlightMatchingPortions)
{
return ImmutableArray.Empty;
}
if (roslynItem.HasDifferentFilterText)
{
// 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.
return completionHelper.GetHighlightedSpans(
item.DisplayText, filterText, CultureInfo.CurrentCulture).SelectAsArray(s => s.ToSpan());
}
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(s => s.MoveTo(roslynItem.DisplayTextPrefix?.Length ?? 0).ToSpan());
}
// 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 ImmutableArray.Empty;
}
}
private static bool MatchesFilterText(
RoslynCompletionItem item,
string filterText,
CompletionTriggerKind initialTriggerKind,
CompletionFilterReason filterReason,
ImmutableArray recentItems,
PatternMatch? patternMatch)
{
// For the deletion we bake in the core logic for how matching should work.
// This way deletion feels the same across all languages that opt into deletion
// as a completion trigger.
// Specifically, to avoid being too aggressive when matching an item during
// completion, we require that the current filter text be a prefix of the
// item in the list.
if (filterReason == CompletionFilterReason.Deletion &&
initialTriggerKind == CompletionTriggerKind.Deletion)
{
return item.FilterText.GetCaseInsensitivePrefixLength(filterText) > 0;
}
// If the user hasn't typed anything, and this item was preselected, or was in the
// MRU list, then we definitely want to include it.
if (filterText.Length == 0)
{
if (item.Rules.MatchPriority > MatchPriority.Default)
{
return true;
}
if (!recentItems.IsDefault && GetRecentItemIndex(recentItems, item) <= 0)
{
return true;
}
}
// Otherwise, the item matches filter text if a pattern match is returned.
return patternMatch != null;
}
private static bool IsHardSelection(
string filterText,
RoslynCompletionItem item,
bool matchedFilterText,
bool useSuggestionMode)
{
if (item == null || useSuggestionMode)
{
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 =
//
// 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 && 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;
}
private static bool IsAllPunctuation(string filterText)
{
foreach (var ch in filterText)
{
if (!char.IsPunctuation(ch))
{
return false;
}
}
return true;
}
///
/// A potential filter character is something that can filter a completion lists and is
/// *guaranteed* to not be a commit character.
///
private static bool IsPotentialFilterCharacter(char c)
{
// TODO(cyrusn): Actually use the right Unicode categories here.
return char.IsLetter(c)
|| char.IsNumber(c)
|| c == '_';
}
}
}