diff --git a/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs b/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs index fc033f8039388..42ee889490476 100644 --- a/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs +++ b/src/EditorFeatures/Core/IntelliSense/AsyncCompletion/ItemManager.CompletionListUpdater.cs @@ -14,6 +14,7 @@ using Microsoft.CodeAnalysis.Collections; using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.PatternMatching; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Text; @@ -159,7 +160,7 @@ public CompletionListUpdater( // take into consideration for things like CompletionTrigger, MatchPriority, MRU, etc. var initialSelection = InitialTriggerReason == CompletionTriggerReason.Backspace || InitialTriggerReason == CompletionTriggerReason.Deletion ? HandleDeletionTrigger(itemsToBeIncluded, cancellationToken) - : HandleNormalFiltering(itemsToBeIncluded, cancellationToken); + : await HandleNormalFilteringAsync(itemsToBeIncluded, cancellationToken).ConfigureAwait(false); if (!initialSelection.HasValue) return null; @@ -339,7 +340,7 @@ int DefaultIndexToFabricatedOriginalSortedIndex(int i) => i - _snapshotData.Defaults.Length; } - private ItemSelection? HandleNormalFiltering(IReadOnlyList matchResults, CancellationToken cancellationToken) + private async Task HandleNormalFilteringAsync(IReadOnlyList matchResults, CancellationToken cancellationToken) { Debug.Assert(matchResults.Count > 0); var filteredMatchResultsBuilder = s_listOfMatchResultPool.Allocate(); @@ -428,7 +429,7 @@ int DefaultIndexToFabricatedOriginalSortedIndex(int i) return null; } - var isHardSelection = IsHardSelection(bestOrFirstMatchResult.CompletionItem, bestOrFirstMatchResult.ShouldBeConsideredMatchingFilterText); + var isHardSelection = await IsHardSelectionAsync(bestOrFirstMatchResult, cancellationToken).ConfigureAwait(false); var updateSelectionHint = isHardSelection ? UpdateSelectionHint.Selected : UpdateSelectionHint.SoftSelected; return new(selectedItemIndex, updateSelectionHint, uniqueItem); @@ -755,13 +756,15 @@ private static bool TryGetInitialTriggerLocation(AsyncCompletionSessionDataSnaps return false; } - private bool IsHardSelection(RoslynCompletionItem item, bool matchedFilterText) + private async Task IsHardSelectionAsync(MatchResult selectedItem, CancellationToken cancellationToken) { if (_hasSuggestedItemOptions) { return false; } + var item = selectedItem.CompletionItem; + // 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 @@ -808,11 +811,21 @@ private bool IsHardSelection(RoslynCompletionItem item, bool matchedFilterText) // 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) + if (!selectedItem.ShouldBeConsideredMatchingFilterText) { return false; } + // When trying to type something like `public TBuilder GetBuilder()`, right after `public TBuilder$` is typed + // We don't want an item like `TypeBuilder` to be hard-selected. Otherwise, typing `space` would automatically change `TBuilder` to `TypeBuilder`, + if (_completionService is not null && + MatchesTypeParameterPattern(_filterText) && + selectedItem.PatternMatch.HasValue && + selectedItem.PatternMatch.Value.Kind > PatternMatchKind.Prefix) + { + return !await _completionService.IsSpeculativeTypeParameterContextAsync(_document!, item.Span.Start, cancellationToken).ConfigureAwait(false); + } + // There was either filter text, or this was a preselect match. In either case, we // can hard select this. return true; @@ -830,6 +843,16 @@ private static bool IsPotentialFilterCharacter(char c) || c == '_'; } + private static bool MatchesTypeParameterPattern(string text) + { + if (string.IsNullOrEmpty(text)) + return false; + + // This is just a very simple heuristic to catch common cases where user is typing a type parameter name in .NET + // Pattern: starts with 'T', and optionally followed by an uppercase letter + return text == "T" || text.Length >= 2 && text[0] == 'T' && char.IsUpper(text[1]); + } + private ItemSelection UpdateSelectionBasedOnSuggestedDefaults(IReadOnlyList items, ItemSelection itemSelection, CancellationToken cancellationToken) { // Editor doesn't provide us a list of "default" items, or we select SuggestionItem (because we are in suggestion mode and have no match in the list) diff --git a/src/EditorFeatures/Test2/IntelliSense/CSharpCompletionCommandHandlerTests.vb b/src/EditorFeatures/Test2/IntelliSense/CSharpCompletionCommandHandlerTests.vb index 6261a07d2f66f..7c9db851ffef4 100644 --- a/src/EditorFeatures/Test2/IntelliSense/CSharpCompletionCommandHandlerTests.vb +++ b/src/EditorFeatures/Test2/IntelliSense/CSharpCompletionCommandHandlerTests.vb @@ -13210,5 +13210,124 @@ public class TestClass1 Await state.AssertSelectedCompletionItem("ref") End Using End Function + + + Public Async Function TestTypeParameterPattern_InMemberDeclaration( + showCompletionInArgumentLists As Boolean, + typeParameter As String) As Task ' not testing "(TBu" here because `TBuilder` would not be in the completion list in this case + Using state = TestStateFactory.CreateCSharpTestState( + +public class TypeBuilder { } +public class C +{ + public <%= typeParameter %>$$ GetBuilder<TBuilder>() { } +} + , + showCompletionInArgumentLists:=showCompletionInArgumentLists) + + state.SendInvokeCompletionList() + Await state.AssertSelectedCompletionItem(displayText:="TBuilder", isHardSelected:=True) ' hard-select TBuilder type parameter (which is in the completion list) + End Using + End Function + + + Public Async Function TestTypeParameterPatternInTuple_InMemberDeclaration( + showCompletionInArgumentLists As Boolean) As Task + Using state = TestStateFactory.CreateCSharpTestState( + +public class TypeBuilder { } +public class C +{ + public (TBu$$ GetBuilder<TBuilder>() { } +} + , + showCompletionInArgumentLists:=showCompletionInArgumentLists) + + state.SendInvokeCompletionList() + Await state.AssertSelectedCompletionItem(displayText:="TypeBuilder", isSoftSelected:=True) ' soft-select TypeBuilder type parameter (because `TBuilder` is not in the completion list) + End Using + End Function + + + Public Async Function TestSpeculativeTypeParameterPattern_InMemberDeclaration( + showCompletionInArgumentLists As Boolean, + typeParameter As String) As Task + Using state = TestStateFactory.CreateCSharpTestState( + +public class TypeBuilder { } +public class C +{ + public <%= typeParameter %>$$ +} + , + showCompletionInArgumentLists:=showCompletionInArgumentLists) + + state.SendInvokeCompletionList() + Await state.AssertSelectedCompletionItem(displayText:="TypeBuilder", isSoftSelected:=True) ' soft-select TypeBuilder class + End Using + End Function + + + Public Async Function TestSpeculativeTypeParameterPattern_InMemberDeclarationNoModifier( + showCompletionInArgumentLists As Boolean, + typeParameter As String) As Task + Using state = TestStateFactory.CreateCSharpTestState( + +public class TypeBuilder { } +public class C +{ + <%= typeParameter %>$$ +} + , + showCompletionInArgumentLists:=showCompletionInArgumentLists) + + state.SendInvokeCompletionList() + Await state.AssertSelectedCompletionItem(displayText:="TypeBuilder", isSoftSelected:=True) ' soft-select TypeBuilder class + End Using + End Function + + + Public Async Function TestTypeParameterPattern_InStatement( + showCompletionInArgumentLists As Boolean, + typeParameter As String) As Task + Using state = TestStateFactory.CreateCSharpTestState( + +public class TypeBuilder { } +public class C +{ + public bool M<TBuilder>() + { + <%= typeParameter %>$$ + } +} + , + showCompletionInArgumentLists:=showCompletionInArgumentLists) + + state.SendInvokeCompletionList() + Await state.AssertSelectedCompletionItem(displayText:="TBuilder", isHardSelected:=True) ' hard-select TBuilder type parameter + End Using + End Function + + + Public Async Function TestSpeculativeTypeParameterPattern_InStatement( + showCompletionInArgumentLists As Boolean, + typeParameter As String) As Task + Using state = TestStateFactory.CreateCSharpTestState( + +public class TypeBuilder { } +public class C +{ + public bool M() + { + <%= typeParameter %>$$ + } +} + , + showCompletionInArgumentLists:=showCompletionInArgumentLists) + + state.SendInvokeCompletionList() + Await state.AssertSelectedCompletionItem(displayText:="TypeBuilder", isHardSelected:=True) ' hard-select TypeBuilder class + End Using + End Function End Class End Namespace diff --git a/src/Features/CSharp/Portable/Completion/CSharpCompletionService.cs b/src/Features/CSharp/Portable/Completion/CSharpCompletionService.cs index 019b7edbe2e75..dc1efe67c8b1b 100644 --- a/src/Features/CSharp/Portable/Completion/CSharpCompletionService.cs +++ b/src/Features/CSharp/Portable/Completion/CSharpCompletionService.cs @@ -5,10 +5,12 @@ using System; using System.Composition; using System.Threading; +using System.Threading.Tasks; using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.CSharp.Completion.Providers; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Text; @@ -65,4 +67,22 @@ internal override CompletionRules GetRules(CompletionOptions options) return newRules; } + + internal override async Task IsSpeculativeTypeParameterContextAsync(Document document, int position, CancellationToken cancellationToken) + { + var syntaxTree = await document.GetRequiredSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + + // Because it's less likely the user wants to type a (undeclared) type parameter when they are inside a method body, treating them so + // might intefere with user intention. For example, while it's fine to provide a speculative `T` item in a statement context, + // since typing 2 characters would filter it out, but for selection, we don't want to soft-select item `TypeBuilder`after `TB` + // is typed in the example below (as if user want to add `TBuilder` to method declaration later): + // + // class C + // { + // void M() + // { + // TB$$ + // } + return CompletionUtilities.IsSpeculativeTypeParameterContext(syntaxTree, position, semanticModel: null, includeStatementContexts: false, cancellationToken); + } } diff --git a/src/Features/CSharp/Portable/Completion/CompletionProviders/CompletionUtilities.cs b/src/Features/CSharp/Portable/Completion/CompletionProviders/CompletionUtilities.cs index e661c7701b67d..763f9ef262b32 100644 --- a/src/Features/CSharp/Portable/Completion/CompletionProviders/CompletionUtilities.cs +++ b/src/Features/CSharp/Portable/Completion/CompletionProviders/CompletionUtilities.cs @@ -6,7 +6,9 @@ using System.Threading; using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.CSharp.Extensions; +using Microsoft.CodeAnalysis.CSharp.Extensions.ContextQuery; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp.Utilities; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.Extensions.ContextQuery; using Microsoft.CodeAnalysis.Text; @@ -238,4 +240,123 @@ public static TextSpan GetTargetSelectionSpanForInsertedMember(SyntaxNode caretT throw ExceptionUtilities.Unreachable(); } } + + /// + /// Determines whether the specified position in the syntax tree is a valid context for speculatively typing + /// a type parameter (which might be undeclared yet). This handles cases where the user may be in the middle of typing a generic type, tuple + /// and ref type as well. + /// + /// For example, when you typed `public TBuilder$$`, you might want to type `public TBuilder M<TBuilder>(){}`, + /// so TBuilder is a valid speculative type parameter context. + /// + public static bool IsSpeculativeTypeParameterContext(SyntaxTree syntaxTree, int position, SemanticModel? semanticModel, bool includeStatementContexts, CancellationToken cancellationToken) + { + var spanStart = position; + + // We could be in the middle of a ref/generic/tuple type, instead of a simple T case. + // If we managed to walk out and get a different SpanStart, we treat it as a simple $$T case. + while (true) + { + var oldSpanStart = spanStart; + + spanStart = WalkOutOfGenericType(syntaxTree, spanStart, semanticModel, cancellationToken); + spanStart = WalkOutOfTupleType(syntaxTree, spanStart, cancellationToken); + spanStart = WalkOutOfRefType(syntaxTree, spanStart, cancellationToken); + + if (spanStart == oldSpanStart) + { + break; + } + } + + var token = syntaxTree.FindTokenOnLeftOfPosition(spanStart, cancellationToken); + + // Always want to allow in member declaration and delegate return type context, for example: + // class C + // { + // public T$$ + // } + // + // delegate T$$ + if (syntaxTree.IsMemberDeclarationContext(spanStart, context: null, SyntaxKindSet.AllMemberModifiers, SyntaxKindSet.NonEnumTypeDeclarations, canBePartial: true, cancellationToken) || + syntaxTree.IsGlobalMemberDeclarationContext(spanStart, SyntaxKindSet.AllGlobalMemberModifiers, cancellationToken) || + syntaxTree.IsDelegateReturnTypeContext(spanStart, token)) + { + return true; + } + + // Because it's less likely the user wants to type a (undeclared) type parameter when they are inside a method body, treating them so + // might intefere with user intention. For example, while it's fine to provide a speculative `T` item in a statement context, + // since typing 2 characters would filter it out, but for selection, we don't want to soft-select item `TypeBuilder`after `TB` + // is typed in the example below (as if user want to add `TBuilder` to method declaration later): + // + // class C + // { + // void M() + // { + // TB$$ + // } + if (includeStatementContexts) + { + return syntaxTree.IsStatementContext(spanStart, token, cancellationToken) || + syntaxTree.IsGlobalStatementContext(spanStart, cancellationToken); + } + + return false; + + static int WalkOutOfGenericType(SyntaxTree syntaxTree, int position, SemanticModel? semanticModel, CancellationToken cancellationToken) + { + var spanStart = position; + var token = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken); + + if (syntaxTree.IsGenericTypeArgumentContext(position, token, cancellationToken, semanticModel)) + { + if (syntaxTree.IsInPartiallyWrittenGeneric(spanStart, cancellationToken, out var nameToken)) + { + spanStart = nameToken.SpanStart; + } + + // If the user types Goo()?.SpanStart ?? spanStart; + } + + var tokenLeftOfGenericName = syntaxTree.FindTokenOnLeftOfPosition(spanStart, cancellationToken); + if (tokenLeftOfGenericName.IsKind(SyntaxKind.DotToken) && tokenLeftOfGenericName.Parent.IsKind(SyntaxKind.QualifiedName)) + { + spanStart = tokenLeftOfGenericName.Parent.SpanStart; + } + } + + return spanStart; + } + + static int WalkOutOfRefType(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken) + { + var prevToken = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken) + .GetPreviousTokenIfTouchingWord(position); + + if (prevToken.Kind() is SyntaxKind.RefKeyword or SyntaxKind.ReadOnlyKeyword && prevToken.Parent.IsKind(SyntaxKind.RefType)) + { + return prevToken.SpanStart; + } + + return position; + } + + static int WalkOutOfTupleType(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken) + { + var prevToken = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken) + .GetPreviousTokenIfTouchingWord(position); + + if (prevToken.IsPossibleTupleOpenParenOrComma()) + { + return prevToken.Parent!.SpanStart; + } + + return position; + } + } } diff --git a/src/Features/CSharp/Portable/Completion/CompletionProviders/SpeculativeTCompletionProvider.cs b/src/Features/CSharp/Portable/Completion/CompletionProviders/SpeculativeTCompletionProvider.cs index 98b96aaba6da2..0d93478ca2457 100644 --- a/src/Features/CSharp/Portable/Completion/CompletionProviders/SpeculativeTCompletionProvider.cs +++ b/src/Features/CSharp/Portable/Completion/CompletionProviders/SpeculativeTCompletionProvider.cs @@ -10,8 +10,6 @@ using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.CSharp.Extensions; using Microsoft.CodeAnalysis.CSharp.Extensions.ContextQuery; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.CSharp.Utilities; using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.Shared.Extensions; @@ -71,95 +69,13 @@ private static async Task ShouldShowSpeculativeTCompletionItemAsync(Docume return false; } - // We could be in the middle of a ref/generic/tuple type, instead of a simple T case. - // If we managed to walk out and get a different SpanStart, we treat it as a simple $$T case. - var context = await completionContext.GetSyntaxContextWithExistingSpeculativeModelAsync(document, cancellationToken).ConfigureAwait(false); if (context.IsTaskLikeTypeContext) return false; - var spanStart = position; - while (true) - { - var oldSpanStart = spanStart; - - spanStart = WalkOutOfGenericType(syntaxTree, spanStart, context.SemanticModel, cancellationToken); - spanStart = WalkOutOfTupleType(syntaxTree, spanStart, cancellationToken); - spanStart = WalkOutOfRefType(syntaxTree, spanStart, cancellationToken); - - if (spanStart == oldSpanStart) - { - break; - } - } - - return IsStartOfSpeculativeTContext(syntaxTree, spanStart, cancellationToken); - } - - private static bool IsStartOfSpeculativeTContext(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken) - { - var token = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken); - - return syntaxTree.IsMemberDeclarationContext(position, context: null, SyntaxKindSet.AllMemberModifiers, SyntaxKindSet.NonEnumTypeDeclarations, canBePartial: true, cancellationToken) || - syntaxTree.IsStatementContext(position, token, cancellationToken) || - syntaxTree.IsGlobalMemberDeclarationContext(position, SyntaxKindSet.AllGlobalMemberModifiers, cancellationToken) || - syntaxTree.IsGlobalStatementContext(position, cancellationToken) || - syntaxTree.IsDelegateReturnTypeContext(position, token); - } - - private static int WalkOutOfGenericType(SyntaxTree syntaxTree, int position, SemanticModel semanticModel, CancellationToken cancellationToken) - { - var spanStart = position; - var token = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken); - - if (syntaxTree.IsGenericTypeArgumentContext(position, token, cancellationToken, semanticModel)) - { - if (syntaxTree.IsInPartiallyWrittenGeneric(spanStart, cancellationToken, out var nameToken)) - { - spanStart = nameToken.SpanStart; - } - - // If the user types Goo()?.SpanStart ?? spanStart; - } - - var tokenLeftOfGenericName = syntaxTree.FindTokenOnLeftOfPosition(spanStart, cancellationToken); - if (tokenLeftOfGenericName.IsKind(SyntaxKind.DotToken) && tokenLeftOfGenericName.Parent.IsKind(SyntaxKind.QualifiedName)) - { - spanStart = tokenLeftOfGenericName.Parent.SpanStart; - } - } - - return spanStart; - } - - private static int WalkOutOfRefType(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken) - { - var prevToken = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken) - .GetPreviousTokenIfTouchingWord(position); - - if (prevToken.Kind() is SyntaxKind.RefKeyword or SyntaxKind.ReadOnlyKeyword && prevToken.Parent.IsKind(SyntaxKind.RefType)) - { - return prevToken.SpanStart; - } - - return position; - } - - private static int WalkOutOfTupleType(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken) - { - var prevToken = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken) - .GetPreviousTokenIfTouchingWord(position); - - if (prevToken.IsPossibleTupleOpenParenOrComma()) - { - return prevToken.Parent!.SpanStart; - } - - return position; + // While it's less likely the user wants to type a (undeclared) type parameter when they are in a statement context, it's probably + // fine to provide a speculative `T` item here since typing 2 characters would easily filter it out. + return CompletionUtilities.IsSpeculativeTypeParameterContext(syntaxTree, position, context.SemanticModel, includeStatementContexts: true, cancellationToken); } } diff --git a/src/Features/Core/Portable/Completion/CompletionService.cs b/src/Features/Core/Portable/Completion/CompletionService.cs index 054077d40fd0b..c880d896d33f9 100644 --- a/src/Features/Core/Portable/Completion/CompletionService.cs +++ b/src/Features/Core/Portable/Completion/CompletionService.cs @@ -375,6 +375,11 @@ internal static bool IsAllPunctuation(string filterText) return true; } + internal virtual Task IsSpeculativeTypeParameterContextAsync(Document document, int position, CancellationToken cancellationToken) + { + return SpecializedTasks.False; + } + /// /// Don't call. Used for pre-populating MEF providers only. ///