diff --git a/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs b/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs index ce88b9b714c40..76729b8579339 100644 --- a/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs +++ b/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs @@ -3,6 +3,7 @@ // 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; @@ -50,6 +51,9 @@ private sealed class CompletionListUpdater private readonly bool _highlightMatchingPortions; private readonly bool _showCompletionItemFilters; + // Used for building MatchResult list in parallel + private readonly object _gate = new(); + private readonly Action, string, IList> _filterMethod; private bool ShouldSelectSuggestionItemWhenNoItemMatchesFilterText @@ -128,20 +132,24 @@ public CompletionListUpdater( // 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(() => 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. - AddCompletionItems(itemsToBeIncluded, cancellationToken); + AddCompletionItems(itemsToBeIncluded, threadLocalPatternMatchHelper, cancellationToken); // 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, cancellationTokenSource.Token), + () => GetHighlightedListAndUpdatedFilters(session, itemsToBeIncluded, threadLocalPatternMatchHelper, cancellationTokenSource.Token), cancellationTokenSource.Token); // Decide the item to be selected for this completion session. @@ -178,12 +186,18 @@ public CompletionListUpdater( // 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, ImmutableArray) GetHighlightedListAndUpdatedFilters( - IAsyncCompletionSession session, IReadOnlyList itemsToBeIncluded, CancellationToken cancellationToken) + IAsyncCompletionSession session, IReadOnlyList itemsToBeIncluded, ThreadLocal patternMatcherHelper, CancellationToken cancellationToken) { - var highLightedList = GetHighlightedList(session, itemsToBeIncluded, cancellationToken); + var highLightedList = GetHighlightedList(session, patternMatcherHelper.Value!, itemsToBeIncluded, cancellationToken); var updatedFilters = GetUpdatedFilters(itemsToBeIncluded, cancellationToken); return (highLightedList, updatedFilters); } @@ -228,7 +242,7 @@ static bool IsAfterDot(ITextSnapshot snapshot, ITrackingSpan applicableToSpan) } } - private void AddCompletionItems(List list, CancellationToken cancellationToken) + private void AddCompletionItems(List list, ThreadLocal threadLocalPatternMatchHelper, CancellationToken cancellationToken) { // Convert initial and update trigger reasons to corresponding Roslyn type so // we can interact with Roslyn's completion system @@ -238,16 +252,19 @@ private void AddCompletionItems(List list, CancellationToken cancel // 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); - using var _1 = PooledHashSet.GetInstance(out var includedPreferredItems); - using var _2 = ArrayBuilder.GetInstance(out var includedDefaults); - var unmatchedDefaults = _snapshotData.Defaults; + var includedPreferredItems = new ConcurrentSet(); + var includedDefaults = new ConcurrentDictionary(); + + Enumerable.Range(0, _snapshotData.InitialSortedItemList.Count) + .AsParallel() + .WithCancellation(cancellationToken) + .ForAll(CreateMatchResultAndProcessMatchingDefaults); - // Filter items based on the selected filters and matching. - var totalCount = _snapshotData.InitialSortedItemList.Count; - for (var currentIndex = 0; currentIndex < totalCount; currentIndex++) + PromoteDefaultItemsToPreferredState(); + + void CreateMatchResultAndProcessMatchingDefaults(int index) { - cancellationToken.ThrowIfCancellationRequested(); - var item = _snapshotData.InitialSortedItemList[currentIndex]; + var item = _snapshotData.InitialSortedItemList[index]; // All items passed in should contain a CompletionItemData object in the property bag, // which is guaranteed in `ItemManager.SortCompletionListAsync`. @@ -255,16 +272,16 @@ private void AddCompletionItems(List list, CancellationToken cancel throw ExceptionUtilities.Unreachable(); if (filterHelper.ShouldBeFilteredOut(item)) - continue; + 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.Sort` isn't stable. - if (CompletionHelper.TryCreateMatchResult(_completionHelper, itemData.RoslynItem, _filterText, - roslynInitialTriggerKind, roslynFilterReason, _recentItemsManager.GetRecentItemIndex(itemData.RoslynItem), _highlightMatchingPortions, currentIndex, - out var matchResult)) + if (threadLocalPatternMatchHelper.Value!.TryCreateMatchResult(itemData.RoslynItem, roslynInitialTriggerKind, roslynFilterReason, + _recentItemsManager.GetRecentItemIndex(itemData.RoslynItem), _highlightMatchingPortions, index, out var matchResult)) { - list.Add(matchResult); + lock (_gate) + list.Add(matchResult); if (!_snapshotData.Defaults.IsEmpty) { @@ -277,26 +294,21 @@ private void AddCompletionItems(List list, CancellationToken cancel } else { - var defaultIndex = unmatchedDefaults.IndexOf(matchResult.CompletionItem.FilterText); - if (defaultIndex >= 0) + if (_snapshotData.Defaults.IndexOf(matchResult.CompletionItem.FilterText) >= 0) { - unmatchedDefaults = unmatchedDefaults.RemoveAt(defaultIndex); - includedDefaults.Add(matchResult); + includedDefaults.TryAdd(matchResult.CompletionItem.FilterText, matchResult); } } } } } - PromoteDefaultItemsToPreferredState(); - list.Sort(MatchResult.SortingComparer); - // 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) + foreach (var includedDefault in includedDefaults.Values) { var completionItem = includedDefault.CompletionItem; @@ -568,6 +580,7 @@ static int GetPriority(RoslynCompletionItem item) private CompletionList GetHighlightedList( IAsyncCompletionSession session, + PatternMatchHelper patternMatchers, IReadOnlyList matchResults, CancellationToken cancellationToken) { @@ -575,16 +588,13 @@ private CompletionList GetHighlightedList( { var vsItem = GetCorrespondingVsCompletionItem(matchResult, cancellationToken); var highlightedSpans = _highlightMatchingPortions - ? GetHighlightedSpans(matchResult, _completionHelper, _filterText) + ? GetHighlightedSpans(matchResult, patternMatchers) : ImmutableArray.Empty; return new CompletionItemWithHighlight(vsItem, highlightedSpans); })); - static ImmutableArray GetHighlightedSpans( - MatchResult matchResult, - CompletionHelper completionHelper, - string filterText) + static ImmutableArray GetHighlightedSpans(MatchResult matchResult, PatternMatchHelper patternMatchers) { if (matchResult.CompletionItem.HasDifferentFilterText || matchResult.CompletionItem.HasAdditionalFilterTexts) { @@ -593,8 +603,8 @@ static ImmutableArray GetHighlightedSpans( // 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 completionHelper.GetHighlightedSpans( - matchResult.CompletionItem, filterText, CultureInfo.CurrentCulture).SelectAsArray(s => s.ToSpan()); + return patternMatchers.GetHighlightedSpans(matchResult.CompletionItem.GetEntireDisplayText(), CultureInfo.CurrentCulture) + .SelectAsArray(s => s.ToSpan()); } var patternMatch = matchResult.PatternMatch; diff --git a/src/EditorFeatures/Test2/IntelliSense/CompletionRulesTests.vb b/src/EditorFeatures/Test2/IntelliSense/CompletionRulesTests.vb index 4ae6ab59d4fb0..d68daca51b92b 100644 --- a/src/EditorFeatures/Test2/IntelliSense/CompletionRulesTests.vb +++ b/src/EditorFeatures/Test2/IntelliSense/CompletionRulesTests.vb @@ -56,7 +56,7 @@ Namespace Microsoft.CodeAnalysis.Editor.UnitTests.IntelliSense Dim culture = New CultureInfo("tr-TR", useUserOverride:=False) Dim workspace = New TestWorkspace - Dim helper = New CompletionHelper(isCaseSensitive:=False) + Dim helper = New PatternMatchHelper(pattern) For Each wordMarkup In wordsToMatch Dim word As String = Nothing @@ -64,26 +64,31 @@ Namespace Microsoft.CodeAnalysis.Editor.UnitTests.IntelliSense MarkupTestFile.GetSpan(wordMarkup, word, wordMatchSpan) Dim item = CompletionItem.Create(word) - Assert.True(helper.MatchesPattern(item, pattern, culture), $"Expected item {word} does not match {pattern}") + Assert.True(helper.MatchesPattern(item, culture), $"Expected item {word} does not match {pattern}") - Dim highlightedSpans = helper.GetHighlightedSpans(item, pattern, culture) + Dim highlightedSpans = helper.GetHighlightedSpans(item.GetEntireDisplayText(), culture) Assert.NotEmpty(highlightedSpans) Assert.Equal(1, highlightedSpans.Length) Assert.Equal(wordMatchSpan, highlightedSpans(0)) Next + + helper.Dispose() End Sub Private Shared Sub TestNotMatches(pattern As String, wordsToNotMatch() As String) Dim culture = New CultureInfo("tr-TR", useUserOverride:=False) Dim workspace = New TestWorkspace - Dim helper = New CompletionHelper(isCaseSensitive:=True) + Dim helper = New PatternMatchHelper(pattern) + For Each word In wordsToNotMatch Dim item = CompletionItem.Create(word) - Assert.False(helper.MatchesPattern(item, pattern, culture), $"Unexpected item {word} matches {pattern}") + Assert.False(helper.MatchesPattern(item, culture), $"Unexpected item {word} matches {pattern}") - Dim highlightedSpans = helper.GetHighlightedSpans(item, pattern, culture) + Dim highlightedSpans = helper.GetHighlightedSpans(item.GetEntireDisplayText(), culture) Assert.Empty(highlightedSpans) Next + + helper.Dispose() End Sub End Class End Namespace diff --git a/src/Features/Core/Portable/Completion/CommonCompletionService.cs b/src/Features/Core/Portable/Completion/CommonCompletionService.cs index 84f325956a85a..b30f3c2ff5a78 100644 --- a/src/Features/Core/Portable/Completion/CommonCompletionService.cs +++ b/src/Features/Core/Portable/Completion/CommonCompletionService.cs @@ -48,8 +48,7 @@ internal override void FilterItems( string filterText, IList builder) { - var helper = CompletionHelper.GetHelper(document); - CompletionService.FilterItems(helper, matchResults, filterText, builder); + CompletionService.FilterItems(CompletionHelper.GetHelper(document), matchResults, filterText, builder); } } } diff --git a/src/Features/Core/Portable/Completion/CompletionContext.cs b/src/Features/Core/Portable/Completion/CompletionContext.cs index cc579d4ec7fc9..1c2482ae76e8b 100644 --- a/src/Features/Core/Portable/Completion/CompletionContext.cs +++ b/src/Features/Core/Portable/Completion/CompletionContext.cs @@ -218,7 +218,7 @@ public CompletionItem? SuggestionModeItem internal Task GetSyntaxContextWithExistingSpeculativeModelAsync(Document document, CancellationToken cancellationToken) { if (SharedSyntaxContextsWithSpeculativeModel is null) - return CompletionHelper.CreateSyntaxContextWithExistingSpeculativeModelAsync(document, Position, cancellationToken); + return Utilities.CreateSyntaxContextWithExistingSpeculativeModelAsync(document, Position, cancellationToken); return SharedSyntaxContextsWithSpeculativeModel.GetSyntaxContextAsync(document, cancellationToken); } diff --git a/src/Features/Core/Portable/Completion/CompletionHelper.cs b/src/Features/Core/Portable/Completion/CompletionHelper.cs index 775f2e4aabe5d..60042f6c3dc28 100644 --- a/src/Features/Core/Portable/Completion/CompletionHelper.cs +++ b/src/Features/Core/Portable/Completion/CompletionHelper.cs @@ -3,28 +3,19 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics; -using System.Globalization; -using System.Threading; -using System.Threading.Tasks; +using Microsoft.CodeAnalysis.LanguageService; using Microsoft.CodeAnalysis.PatternMatching; using Microsoft.CodeAnalysis.Shared.Extensions; -using Microsoft.CodeAnalysis.Shared.Extensions.ContextQuery; using Microsoft.CodeAnalysis.Tags; -using Microsoft.CodeAnalysis.Text; -using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.Completion { internal sealed class CompletionHelper { - private readonly object _gate = new(); - private readonly Dictionary<(string pattern, CultureInfo, bool includeMatchedSpans), PatternMatcher> _patternMatcherMap = - new(); + private static CompletionHelper CaseSensitiveInstance { get; } = new CompletionHelper(isCaseSensitive: true); + private static CompletionHelper CaseInsensitiveInstance { get; } = new CompletionHelper(isCaseSensitive: false); - private static readonly CultureInfo EnUSCultureInfo = new("en-US"); private readonly bool _isCaseSensitive; public CompletionHelper(bool isCaseSensitive) @@ -32,105 +23,12 @@ public CompletionHelper(bool isCaseSensitive) public static CompletionHelper GetHelper(Document document) { - return document.Project.Solution.Services.GetRequiredService() - .GetCompletionHelper(document); - } - - public ImmutableArray GetHighlightedSpans( - CompletionItem item, string pattern, CultureInfo culture) - { - var match = GetMatch(item.GetEntireDisplayText(), pattern, includeMatchSpans: true, culture: culture); - return match == null ? ImmutableArray.Empty : match.Value.MatchedSpans; - } - - /// - /// Returns true if the completion item matches the pattern so far. Returns 'true' - /// if and only if the completion item matches and should be included in the filtered completion - /// results, or false if it should not be. - /// - public bool MatchesPattern(CompletionItem item, string pattern, CultureInfo culture) - => GetMatchResult(item, pattern, includeMatchSpans: false, culture).ShouldBeConsideredMatchingFilterText; - - public MatchResult GetMatchResult( - CompletionItem item, - string pattern, - bool includeMatchSpans, - CultureInfo culture) - { - var match = GetMatch(item.FilterText, pattern, includeMatchSpans, culture); - string? matchedAdditionalFilterText = null; + var syntaxFacts = document.GetLanguageService(); + var caseSensitive = syntaxFacts?.IsCaseSensitive ?? true; - if (item.HasAdditionalFilterTexts) - { - foreach (var additionalFilterText in item.AdditionalFilterTexts) - { - var additionalMatch = GetMatch(additionalFilterText, pattern, includeMatchSpans, culture); - if (additionalMatch.HasValue && additionalMatch.Value.CompareTo(match, ignoreCase: false) < 0) - { - match = additionalMatch; - matchedAdditionalFilterText = additionalFilterText; - } - } - } - - return new MatchResult( - item, - shouldBeConsideredMatchingFilterText: match is not null, - match, - index: -1, - matchedAdditionalFilterText); - } - - private PatternMatch? GetMatch(string text, string pattern, bool includeMatchSpans, CultureInfo culture) - { - var patternMatcher = GetPatternMatcher(pattern, culture, includeMatchSpans, _patternMatcherMap); - var match = patternMatcher.GetFirstMatch(text); - - // We still have making checks for language having different to English capitalization, - // for example, for Turkish with dotted and dotless i capitalization totally diferent from English. - // Now we escaping from the second check for English languages. - // Maybe we can escape as well for more similar languages in case if we meet performance issues. - if (culture.ThreeLetterWindowsLanguageName.Equals(EnUSCultureInfo.ThreeLetterWindowsLanguageName)) - { - return match; - } - - // Keywords in .NET are always in En-US. - // Identifiers can be in user language. - // Try to get matches for both and return the best of them. - patternMatcher = GetPatternMatcher(pattern, EnUSCultureInfo, includeMatchSpans, _patternMatcherMap); - var enUSCultureMatch = patternMatcher.GetFirstMatch(text); - - if (match == null) - { - return enUSCultureMatch; - } - - if (enUSCultureMatch == null) - { - return match; - } - - return match.Value.CompareTo(enUSCultureMatch.Value) < 0 ? match.Value : enUSCultureMatch.Value; - } - - private PatternMatcher GetPatternMatcher( - string pattern, CultureInfo culture, bool includeMatchedSpans, - Dictionary<(string, CultureInfo, bool), PatternMatcher> map) - { - lock (_gate) - { - var key = (pattern, culture, includeMatchedSpans); - if (!map.TryGetValue(key, out var patternMatcher)) - { - patternMatcher = PatternMatcher.CreatePatternMatcher( - pattern, culture, includeMatchedSpans, - allowFuzzyMatching: false); - map.Add(key, patternMatcher); - } - - return patternMatcher; - } + return caseSensitive + ? CaseSensitiveInstance + : CaseInsensitiveInstance; } public int CompareMatchResults(MatchResult matchResult1, MatchResult matchResult2, bool filterTextHasNoUpperCase) @@ -378,131 +276,5 @@ private static int CompareExpandedItem(CompletionItem item1, PatternMatch match1 isItem2Expanded && match2.Kind == PatternMatchKind.Exact && !isItem1Expanded && match1.Kind > PatternMatchKind.Prefix); return isItem1Expanded ? -1 : 1; } - - internal static bool TryCreateMatchResult( - CompletionHelper completionHelper, - CompletionItem item, - string pattern, - CompletionTriggerKind initialTriggerKind, - CompletionFilterReason filterReason, - int recentItemIndex, - bool includeMatchSpans, - int currentIndex, - out MatchResult matchResult) - { - // 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 = pattern.Length > 0 - ? completionHelper.GetMatch(item.FilterText, pattern, includeMatchSpans, CultureInfo.CurrentCulture) - : null; - - string? matchedAdditionalFilterText = null; - var shouldBeConsideredMatchingFilterText = ShouldBeConsideredMatchingFilterText( - item.FilterText, - pattern, - item.Rules.MatchPriority, - initialTriggerKind, - filterReason, - recentItemIndex, - patternMatch); - - if (pattern.Length > 0 && item.HasAdditionalFilterTexts) - { - foreach (var additionalFilterText in item.AdditionalFilterTexts) - { - var additionalMatch = completionHelper.GetMatch(additionalFilterText, pattern, includeMatchSpans, CultureInfo.CurrentCulture); - var additionalFlag = ShouldBeConsideredMatchingFilterText( - additionalFilterText, - pattern, - item.Rules.MatchPriority, - initialTriggerKind, - filterReason, - recentItemIndex, - additionalMatch); - - if (!shouldBeConsideredMatchingFilterText || - additionalFlag && additionalMatch.HasValue && additionalMatch.Value.CompareTo(patternMatch, ignoreCase: false) < 0) - { - matchedAdditionalFilterText = additionalFilterText; - shouldBeConsideredMatchingFilterText = additionalFlag; - patternMatch = additionalMatch; - } - } - } - - if (shouldBeConsideredMatchingFilterText || KeepAllItemsInTheList(initialTriggerKind, pattern)) - { - matchResult = new MatchResult( - item, shouldBeConsideredMatchingFilterText, - patternMatch, currentIndex, matchedAdditionalFilterText, recentItemIndex); - - return true; - } - - matchResult = default; - return false; - - static bool ShouldBeConsideredMatchingFilterText( - string filterText, - string pattern, - int matchPriority, - CompletionTriggerKind initialTriggerKind, - CompletionFilterReason filterReason, - int recentItemIndex, - 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 filterText.GetCaseInsensitivePrefixLength(pattern) > 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 (pattern.Length == 0) - { - if (recentItemIndex >= 0 || matchPriority > MatchPriority.Default) - return true; - } - - // Otherwise, the item matches filter text if a pattern match is returned. - return patternMatch != null; - } - - // 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. - static bool KeepAllItemsInTheList(CompletionTriggerKind initialTriggerKind, string filterText) - { - return filterText.Length <= 1 || - initialTriggerKind == CompletionTriggerKind.Invoke || - initialTriggerKind == CompletionTriggerKind.Deletion; - } - } - - public static async Task CreateSyntaxContextWithExistingSpeculativeModelAsync(Document document, int position, CancellationToken cancellationToken) - { - Contract.ThrowIfFalse(document.SupportsSemanticModel, "Should only be called from C#/VB providers."); - var semanticModel = await document.ReuseExistingSpeculativeModelAsync(position, cancellationToken).ConfigureAwait(false); - - var service = document.GetRequiredLanguageService(); - return service.CreateContext(document, semanticModel, position, cancellationToken); - } } } diff --git a/src/Features/Core/Portable/Completion/CompletionHelperServiceFactory.cs b/src/Features/Core/Portable/Completion/CompletionHelperServiceFactory.cs deleted file mode 100644 index 1a2f95d7d37e6..0000000000000 --- a/src/Features/Core/Portable/Completion/CompletionHelperServiceFactory.cs +++ /dev/null @@ -1,81 +0,0 @@ -// 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.Composition; -using Microsoft.CodeAnalysis.Host; -using Microsoft.CodeAnalysis.Host.Mef; -using Microsoft.CodeAnalysis.LanguageService; -using Microsoft.CodeAnalysis.Shared.Extensions; -using Roslyn.Utilities; - -namespace Microsoft.CodeAnalysis.Completion -{ - [ExportWorkspaceServiceFactory(typeof(ICompletionHelperService)), Shared] - internal class CompletionHelperServiceFactory : IWorkspaceServiceFactory - { - [ImportingConstructor] - [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] - public CompletionHelperServiceFactory() - { - } - - public IWorkspaceService CreateService(HostWorkspaceServices workspaceServices) - => new Service(workspaceServices.Workspace); - - private sealed class Service : ICompletionHelperService, IWorkspaceService - { - private readonly object _gate = new(); - - private CompletionHelper? _lazyCaseSensitiveInstance; - private CompletionHelper? _lazyCaseInsensitiveInstance; - - public Service(Workspace workspace) - => workspace.WorkspaceChanged += OnWorkspaceChanged; - - public CompletionHelper GetCompletionHelper(Document document) - { - lock (_gate) - { - // Don't bother creating instances unless we actually need them - if (_lazyCaseSensitiveInstance == null) - { - CreateInstances(); - } - - Contract.ThrowIfNull(_lazyCaseSensitiveInstance); - Contract.ThrowIfNull(_lazyCaseInsensitiveInstance); - - var syntaxFacts = document.GetLanguageService(); - var caseSensitive = syntaxFacts?.IsCaseSensitive ?? true; - - return caseSensitive - ? _lazyCaseSensitiveInstance - : _lazyCaseInsensitiveInstance; - } - } - - private void CreateInstances() - { - _lazyCaseSensitiveInstance = new CompletionHelper(isCaseSensitive: true); - _lazyCaseInsensitiveInstance = new CompletionHelper(isCaseSensitive: false); - } - - private void OnWorkspaceChanged(object? sender, WorkspaceChangeEventArgs e) - { - if (e.Kind == WorkspaceChangeKind.SolutionRemoved) - { - lock (_gate) - { - // Solution was unloaded, clear caches if we were caching anything - if (_lazyCaseSensitiveInstance != null) - { - CreateInstances(); - } - } - } - } - } - } -} diff --git a/src/Features/Core/Portable/Completion/CompletionService.cs b/src/Features/Core/Portable/Completion/CompletionService.cs index 17b74df48ecde..f847351cbc7b4 100644 --- a/src/Features/Core/Portable/Completion/CompletionService.cs +++ b/src/Features/Core/Portable/Completion/CompletionService.cs @@ -11,7 +11,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Collections; -using Microsoft.CodeAnalysis.Completion.Providers; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.LanguageService; using Microsoft.CodeAnalysis.Options; @@ -252,14 +251,14 @@ public virtual ImmutableArray FilterItems( ImmutableArray items, string filterText) { - var helper = CompletionHelper.GetHelper(document); - var filterDataList = new SegmentedList(items.Select( - item => helper.GetMatchResult(item, filterText, includeMatchSpans: false, CultureInfo.CurrentCulture))); + using var helper = new PatternMatchHelper(filterText); + var filterDataList = new SegmentedList( + items.Select(item => helper.GetMatchResult(item, includeMatchSpans: false, CultureInfo.CurrentCulture))); var builder = s_listOfMatchResultPool.Allocate(); try { - FilterItems(helper, filterDataList, filterText, builder); + FilterItems(CompletionHelper.GetHelper(document), filterDataList, filterText, builder); return builder.SelectAsArray(result => result.CompletionItem); } finally @@ -281,8 +280,8 @@ internal virtual void FilterItems( var filteredItems = FilterItems(document, matchResults.SelectAsArray(item => item.CompletionItem), filterText); #pragma warning restore RS0030 // Do not used banned APIs - var helper = CompletionHelper.GetHelper(document); - builder.AddRange(filteredItems.Select(item => helper.GetMatchResult(item, filterText, includeMatchSpans: false, CultureInfo.CurrentCulture))); + using var completionPatternMatchers = new PatternMatchHelper(filterText); + builder.AddRange(filteredItems.Select(item => completionPatternMatchers.GetMatchResult(item, includeMatchSpans: false, CultureInfo.CurrentCulture))); } /// diff --git a/src/Features/Core/Portable/Completion/ICompletionHelperService.cs b/src/Features/Core/Portable/Completion/ICompletionHelperService.cs deleted file mode 100644 index b4480b8956791..0000000000000 --- a/src/Features/Core/Portable/Completion/ICompletionHelperService.cs +++ /dev/null @@ -1,13 +0,0 @@ -// 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 Microsoft.CodeAnalysis.Host; - -namespace Microsoft.CodeAnalysis.Completion -{ - internal interface ICompletionHelperService : IWorkspaceService - { - CompletionHelper GetCompletionHelper(Document document); - } -} diff --git a/src/Features/Core/Portable/Completion/PatternMatchHelper.cs b/src/Features/Core/Portable/Completion/PatternMatchHelper.cs new file mode 100644 index 0000000000000..26d86e6f002e8 --- /dev/null +++ b/src/Features/Core/Portable/Completion/PatternMatchHelper.cs @@ -0,0 +1,249 @@ +// 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.Globalization; +using Microsoft.CodeAnalysis.PatternMatching; +using Microsoft.CodeAnalysis.Text; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.Completion +{ + /// + /// This type is not thread safe due to the restriction of underlying PatternMatcher. + /// Must be disposed after use. + /// + internal sealed class PatternMatchHelper : IDisposable + { + private readonly object _gate = new(); + private readonly Dictionary<(CultureInfo, bool includeMatchedSpans), PatternMatcher> _patternMatcherMap = new(); + + private static readonly CultureInfo EnUSCultureInfo = new("en-US"); + + public string Pattern { get; } + + public PatternMatchHelper(string pattern) + { + Pattern = pattern; + } + + public ImmutableArray GetHighlightedSpans(string text, CultureInfo culture) + { + var match = GetMatch(text, includeMatchSpans: true, culture: culture); + return match == null ? ImmutableArray.Empty : match.Value.MatchedSpans; + } + + public PatternMatch? GetMatch(string text, bool includeMatchSpans, CultureInfo culture) + { + var patternMatcher = GetPatternMatcher(culture, includeMatchSpans); + var match = patternMatcher.GetFirstMatch(text); + + // We still have making checks for language having different to English capitalization, + // for example, for Turkish with dotted and dotless i capitalization totally diferent from English. + // Now we escaping from the second check for English languages. + // Maybe we can escape as well for more similar languages in case if we meet performance issues. + if (culture.ThreeLetterWindowsLanguageName.Equals(EnUSCultureInfo.ThreeLetterWindowsLanguageName)) + { + return match; + } + + // Keywords in .NET are always in En-US. + // Identifiers can be in user language. + // Try to get matches for both and return the best of them. + patternMatcher = GetPatternMatcher(EnUSCultureInfo, includeMatchSpans); + var enUSCultureMatch = patternMatcher.GetFirstMatch(text); + + if (match == null) + { + return enUSCultureMatch; + } + + if (enUSCultureMatch == null) + { + return match; + } + + return match.Value.CompareTo(enUSCultureMatch.Value) < 0 ? match.Value : enUSCultureMatch.Value; + } + + private PatternMatcher GetPatternMatcher(CultureInfo culture, bool includeMatchedSpans) + { + lock (_gate) + { + var key = (culture, includeMatchedSpans); + if (!_patternMatcherMap.TryGetValue(key, out var patternMatcher)) + { + patternMatcher = PatternMatcher.CreatePatternMatcher( + Pattern, culture, includeMatchedSpans, + allowFuzzyMatching: false); + _patternMatcherMap.Add(key, patternMatcher); + } + + return patternMatcher; + } + } + + public MatchResult GetMatchResult( + CompletionItem item, + bool includeMatchSpans, + CultureInfo culture) + { + var match = GetMatch(item.FilterText, includeMatchSpans, culture); + string? matchedAdditionalFilterText = null; + + if (item.HasAdditionalFilterTexts) + { + foreach (var additionalFilterText in item.AdditionalFilterTexts) + { + var additionalMatch = GetMatch(additionalFilterText, includeMatchSpans, culture); + if (additionalMatch.HasValue && additionalMatch.Value.CompareTo(match, ignoreCase: false) < 0) + { + match = additionalMatch; + matchedAdditionalFilterText = additionalFilterText; + } + } + } + + return new MatchResult( + item, + shouldBeConsideredMatchingFilterText: match is not null, + match, + index: -1, + matchedAdditionalFilterText); + } + + /// + /// Returns true if the completion item matches the pattern so far. Returns 'true' + /// if and only if the completion item matches and should be included in the filtered completion + /// results, or false if it should not be. + /// + public bool MatchesPattern(CompletionItem item, CultureInfo culture) + => GetMatchResult(item, includeMatchSpans: false, culture).ShouldBeConsideredMatchingFilterText; + + public bool TryCreateMatchResult( + CompletionItem item, + CompletionTriggerKind initialTriggerKind, + CompletionFilterReason filterReason, + int recentItemIndex, + bool includeMatchSpans, + int currentIndex, + out MatchResult matchResult) + { + // 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 = Pattern.Length > 0 + ? GetMatch(item.FilterText, includeMatchSpans, CultureInfo.CurrentCulture) + : null; + + string? matchedAdditionalFilterText = null; + var shouldBeConsideredMatchingFilterText = ShouldBeConsideredMatchingFilterText( + item.FilterText, + item.Rules.MatchPriority, + initialTriggerKind, + filterReason, + recentItemIndex, + patternMatch); + + if (Pattern.Length > 0 && item.HasAdditionalFilterTexts) + { + foreach (var additionalFilterText in item.AdditionalFilterTexts) + { + var additionalMatch = GetMatch(additionalFilterText, includeMatchSpans, CultureInfo.CurrentCulture); + var additionalFlag = ShouldBeConsideredMatchingFilterText( + additionalFilterText, + item.Rules.MatchPriority, + initialTriggerKind, + filterReason, + recentItemIndex, + additionalMatch); + + if (!shouldBeConsideredMatchingFilterText || + additionalFlag && additionalMatch.HasValue && additionalMatch.Value.CompareTo(patternMatch, ignoreCase: false) < 0) + { + matchedAdditionalFilterText = additionalFilterText; + shouldBeConsideredMatchingFilterText = additionalFlag; + patternMatch = additionalMatch; + } + } + } + + if (shouldBeConsideredMatchingFilterText || KeepAllItemsInTheList(initialTriggerKind, Pattern)) + { + matchResult = new MatchResult( + item, shouldBeConsideredMatchingFilterText, + patternMatch, currentIndex, matchedAdditionalFilterText, recentItemIndex); + + return true; + } + + matchResult = default; + return false; + + bool ShouldBeConsideredMatchingFilterText( + string filterText, + int matchPriority, + CompletionTriggerKind initialTriggerKind, + CompletionFilterReason filterReason, + int recentItemIndex, + 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 filterText.GetCaseInsensitivePrefixLength(Pattern) > 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 (Pattern.Length == 0) + { + if (recentItemIndex >= 0 || matchPriority > MatchPriority.Default) + return true; + } + + // Otherwise, the item matches filter text if a pattern match is returned. + return patternMatch != null; + } + + // 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. + static bool KeepAllItemsInTheList(CompletionTriggerKind initialTriggerKind, string filterText) + { + return filterText.Length <= 1 || + initialTriggerKind == CompletionTriggerKind.Invoke || + initialTriggerKind == CompletionTriggerKind.Deletion; + } + } + + public void Dispose() + { + lock (_gate) + { + foreach (var matcher in _patternMatcherMap.Values) + matcher.Dispose(); + + _patternMatcherMap.Clear(); + } + } + } +} diff --git a/src/Features/Core/Portable/Completion/Providers/AbstractRecommendationServiceBasedCompletionProvider.cs b/src/Features/Core/Portable/Completion/Providers/AbstractRecommendationServiceBasedCompletionProvider.cs index 19aebabda4b36..457646437239a 100644 --- a/src/Features/Core/Portable/Completion/Providers/AbstractRecommendationServiceBasedCompletionProvider.cs +++ b/src/Features/Core/Portable/Completion/Providers/AbstractRecommendationServiceBasedCompletionProvider.cs @@ -209,7 +209,7 @@ internal sealed override async Task GetDescriptionWorkerA foreach (var relatedId in relatedDocumentIds) { var relatedDocument = document.Project.Solution.GetRequiredDocument(relatedId); - var context = await CompletionHelper.CreateSyntaxContextWithExistingSpeculativeModelAsync(relatedDocument, position, cancellationToken).ConfigureAwait(false) as TSyntaxContext; + var context = await Utilities.CreateSyntaxContextWithExistingSpeculativeModelAsync(relatedDocument, position, cancellationToken).ConfigureAwait(false) as TSyntaxContext; Contract.ThrowIfNull(context); var symbols = await TryGetSymbolsForContextAsync(completionContext: null, context, options, cancellationToken).ConfigureAwait(false); diff --git a/src/Features/Core/Portable/Completion/SharedSyntaxContextsWithSpeculativeModel.cs b/src/Features/Core/Portable/Completion/SharedSyntaxContextsWithSpeculativeModel.cs index 011b60958d0ed..8d01f369e8508 100644 --- a/src/Features/Core/Portable/Completion/SharedSyntaxContextsWithSpeculativeModel.cs +++ b/src/Features/Core/Portable/Completion/SharedSyntaxContextsWithSpeculativeModel.cs @@ -44,7 +44,7 @@ public Task GetSyntaxContextAsync(Document document, Cancellation static AsyncLazy GetLazySyntaxContextWithSpeculativeModel(Document document, SharedSyntaxContextsWithSpeculativeModel self) { return self._cache.GetOrAdd(document, d => AsyncLazy.Create(cancellationToken - => CompletionHelper.CreateSyntaxContextWithExistingSpeculativeModelAsync(d, self._position, cancellationToken), cacheResult: true)); + => Utilities.CreateSyntaxContextWithExistingSpeculativeModelAsync(d, self._position, cancellationToken), cacheResult: true)); } } } diff --git a/src/Features/Core/Portable/Completion/Utilities.cs b/src/Features/Core/Portable/Completion/Utilities.cs index 19ed06bd8daa9..2847e55d9f8aa 100644 --- a/src/Features/Core/Portable/Completion/Utilities.cs +++ b/src/Features/Core/Portable/Completion/Utilities.cs @@ -2,11 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -#nullable disable - using System.Collections.Immutable; +using Roslyn.Utilities; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Shared.Extensions.ContextQuery; using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.Shared.Extensions; namespace Microsoft.CodeAnalysis.Completion { @@ -33,7 +36,7 @@ public static TextChange Collapse(SourceText newText, ImmutableArray // span's length + all the deltas we accumulate through each text change. i.e. // if the first change adds 2 characters and the second change adds 4, then // the newSpan will be 2+4=6 characters longer than the old span. - var sumOfDeltas = changes.Sum(c => c.NewText.Length - c.Span.Length); + var sumOfDeltas = changes.Sum(c => c.NewText!.Length - c.Span.Length); var totalNewSpan = new TextSpan(totalOldSpan.Start, totalOldSpan.Length + sumOfDeltas); return new TextChange(totalOldSpan, newText.ToString(totalNewSpan)); @@ -45,5 +48,14 @@ public static bool IsPreferredItem(this CompletionItem completionItem) => completionItem.DisplayText.StartsWith(UnicodeStarAndSpace); public const string UnicodeStarAndSpace = "\u2605 "; + + public static async Task CreateSyntaxContextWithExistingSpeculativeModelAsync(Document document, int position, CancellationToken cancellationToken) + { + Contract.ThrowIfFalse(document.SupportsSemanticModel, "Should only be called from C#/VB providers."); + var semanticModel = await document.ReuseExistingSpeculativeModelAsync(position, cancellationToken).ConfigureAwait(false); + + var service = document.GetRequiredLanguageService(); + return service.CreateContext(document, semanticModel, position, cancellationToken); + } } } diff --git a/src/Features/Core/Portable/ExternalAccess/VSTypeScript/Api/VSTypeScriptCompletionServiceWithProviders.cs b/src/Features/Core/Portable/ExternalAccess/VSTypeScript/Api/VSTypeScriptCompletionServiceWithProviders.cs index 096e6bd962396..3389a4ec8021b 100644 --- a/src/Features/Core/Portable/ExternalAccess/VSTypeScript/Api/VSTypeScriptCompletionServiceWithProviders.cs +++ b/src/Features/Core/Portable/ExternalAccess/VSTypeScript/Api/VSTypeScriptCompletionServiceWithProviders.cs @@ -42,8 +42,8 @@ internal virtual void FilterItemsImpl( var filteredItems = FilterItems(document, matchResults.SelectAsArray(item => item.CompletionItem), filterText); #pragma warning restore RS0030 // Do not used banned APIs - var helper = CompletionHelper.GetHelper(document); - builder.AddRange(filteredItems.Select(item => helper.GetMatchResult(item, filterText, includeMatchSpans: false, CultureInfo.CurrentCulture))); + using var helper = new PatternMatchHelper(filterText); + builder.AddRange(filteredItems.Select(item => helper.GetMatchResult(item, includeMatchSpans: false, CultureInfo.CurrentCulture))); } } } diff --git a/src/Features/LanguageServer/Protocol/Handler/Completion/CompletionHandler.cs b/src/Features/LanguageServer/Protocol/Handler/Completion/CompletionHandler.cs index 08c0807ed1a9e..86f0602d5ee3e 100644 --- a/src/Features/LanguageServer/Protocol/Handler/Completion/CompletionHandler.cs +++ b/src/Features/LanguageServer/Protocol/Handler/Completion/CompletionHandler.cs @@ -333,7 +333,7 @@ static void PromoteCommonCommitCharactersOntoList(LSP.VSInternalCompletionList c var resultId = result.Value.ResultId; var completionListMaxSize = _globalOptions.GetOption(LspOptionsStorage.MaxCompletionListSize); - var (completionList, isIncomplete) = FilterCompletionList(result.Value.List, completionListMaxSize, completionListSpan, completionTrigger, sourceText, document); + var (completionList, isIncomplete) = FilterCompletionList(result.Value.List, completionListMaxSize, completionListSpan, completionTrigger, sourceText); return (completionList, isIncomplete, resultId); } @@ -366,21 +366,18 @@ private static (CompletionList CompletionList, bool IsIncomplete) FilterCompleti int completionListMaxSize, TextSpan completionListSpan, CompletionTrigger completionTrigger, - SourceText sourceText, - Document document) + SourceText sourceText) { var filterText = sourceText.GetSubText(completionListSpan).ToString(); // Use pattern matching to determine which items are most relevant out of the calculated items. using var _ = ArrayBuilder.GetInstance(out var matchResultsBuilder); var index = 0; - var completionHelper = CompletionHelper.GetHelper(document); + using var helper = new PatternMatchHelper(filterText); foreach (var item in completionList.ItemsList) { - if (CompletionHelper.TryCreateMatchResult( - completionHelper, + if (helper.TryCreateMatchResult( item, - filterText, completionTrigger.Kind, GetFilterReason(completionTrigger), recentItemIndex: -1,