Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix commit behvaior where items use default commit chars as their special filter char #70743

Merged
merged 1 commit into from
Nov 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,17 @@ public bool ShouldCommitCompletion(
char typedChar,
CancellationToken cancellationToken)
{
if (!PotentialCommitCharacters.Contains(typedChar))
{
return false;
}

return !(session.Properties.TryGetProperty(CompletionSource.ExcludedCommitCharacters, out ImmutableArray<char> excludedCommitCharacter)
&& excludedCommitCharacter.Contains(typedChar));
// this is called only when the typedChar is in the list returned by PotentialCommitCharacters.
// It's possible typedChar is intended to be a filter char for some items (either currently considered for commit of not)
// we let this case to be handled in `TryCommit` instead, where we will have all the information needed to decide.
return true;
}

public AsyncCompletionData.CommitResult TryCommit(
IAsyncCompletionSession session,
ITextBuffer subjectBuffer,
VSCompletionItem item,
char typeChar,
char typedChar,
CancellationToken cancellationToken)
{
// We can make changes to buffers. We would like to be sure nobody can change them at the same time.
Expand All @@ -121,20 +118,35 @@ public AsyncCompletionData.CommitResult TryCommit(
return CommitResultUnhandled;
}

var filterText = session.ApplicableToSpan.GetText(session.ApplicableToSpan.TextBuffer.CurrentSnapshot) + typeChar;
if (Helpers.IsFilterCharacter(itemData.RoslynItem, typeChar, filterText))
var roslynItem = itemData.RoslynItem;
var filterText = session.ApplicableToSpan.GetText(session.ApplicableToSpan.TextBuffer.CurrentSnapshot) + typedChar;

if (Helpers.IsFilterCharacter(roslynItem, typedChar, filterText))
{
// Returning Cancel means we keep the current session and consider the character for further filtering.
return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.CancelCommit);
}

// typedChar could be a filter character for another item. If we find such an item that the current filter
// text matches its start, then we should cancel commit and give ItemManager a chance to handle it.
// This is done here instead of in `ShouldCommitCompletion` because `ShouldCommitCompletion` might be called before
// CompletionSource add the `excludedCommitCharactersMap` to the session property bag.
if (session.Properties.TryGetProperty(CompletionSource.ExcludedCommitCharactersMap, out MultiDictionary<char, RoslynCompletionItem> excludedCommitCharactersMap))
{
foreach (var potentialItemForSelection in excludedCommitCharactersMap[typedChar])
{
if (potentialItemForSelection != roslynItem && Helpers.TextTypedSoFarMatchesItem(potentialItemForSelection, filterText))
return new AsyncCompletionData.CommitResult(isHandled: true, AsyncCompletionData.CommitBehavior.CancelCommit);
}
}

var options = _globalOptions.GetCompletionOptions(document.Project.Language);
var serviceRules = completionService.GetRules(options);

// We can be called before for ShouldCommitCompletion. However, that call does not provide rules applied for the completion item.
// Now we check for the commit character in the context of Rules that could change the list of commit characters.

if (!Helpers.IsStandardCommitCharacter(typeChar) && !IsCommitCharacter(serviceRules, itemData.RoslynItem, typeChar))
if (!Helpers.IsStandardCommitCharacter(typedChar) && !IsCommitCharacter(serviceRules, roslynItem, typedChar))
{
// Returning None means we complete the current session with a void commit.
// The Editor then will try to trigger a new completion session for the character.
Expand Down Expand Up @@ -162,10 +174,10 @@ public AsyncCompletionData.CommitResult TryCommit(
}

// Commit with completion service assumes that null is provided is case of invoke. VS provides '\0' in the case.
var commitChar = typeChar == '\0' ? null : (char?)typeChar;
var commitChar = typedChar == '\0' ? null : (char?)typedChar;
return Commit(
session, triggerDocument, completionService, subjectBuffer,
itemData.RoslynItem, sessionData.CompletionListSpan.Value, commitChar, itemData.TriggerLocation.Value.Snapshot, serviceRules,
roslynItem, sessionData.CompletionListSpan.Value, commitChar, itemData.TriggerLocation.Value.Snapshot, serviceRules,
filterText, cancellationToken);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ internal sealed class CompletionSource : IAsyncExpandingCompletionSource

// Don't change this property! Editor code currently has a dependency on it.
internal const string ExcludedCommitCharacters = nameof(ExcludedCommitCharacters);
internal const string ExcludedCommitCharactersMap = nameof(ExcludedCommitCharactersMap);

private static readonly ImmutableArray<ImageElement> s_warningImageAttributeImagesArray =
ImmutableArray.Create(new ImageElement(Glyph.CompletionWarning.GetImageId(), EditorFeaturesResources.Warning_image_element));
Expand Down Expand Up @@ -425,16 +426,24 @@ private static void UpdateSessionData(IAsyncCompletionSession session, Completio
// If there are suggestionItemOptions, then later HandleNormalFiltering should set selection to SoftSelection.
sessionData.HasSuggestionItemOptions |= completionList.SuggestionModeItem != null;

var excludedCommitCharacters = GetExcludedCommitCharacters(completionList.ItemsList);
if (excludedCommitCharacters.Length > 0)
var excludedCommitCharactersFromList = GetExcludedCommitCharacters(completionList.ItemsList);
if (session.Properties.TryGetProperty(ExcludedCommitCharactersMap, out MultiDictionary<char, RoslynCompletionItem> excludedCommitCharactersMap))
{
if (session.Properties.TryGetProperty(ExcludedCommitCharacters, out ImmutableArray<char> excludedCommitCharactersBefore))
foreach (var kvp in excludedCommitCharactersFromList)
Copy link
Contributor

@ToddGrun ToddGrun Nov 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

foreach (var kvp in excludedCommitCharactersFromList)

nit: would be nice if there were an AddRange on MultiDictionary. Would simplify this code and it's implementation would be slightly more performant as it would only need to look up each key once

Copy link
Member Author

@genlu genlu Nov 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think AddRange is usually associated with list like types in BCL. Maybe we could add a Union for MultiDictionary? I can give this a try in a follow up PR since it'd require change in compiler layer. Also import compeltion uses this type extensively so I'd like to take that into consideration too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I just saw that OrderedMultiDictionary has an AddRange, so I thought the name could match that, but Union is fine by me too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, this method must get called on the same session multiple times with different completionList.ItemsList values, right? Just trying to understand if there is a relation between previous values in the ExcludedCommitCharactersMap property and the items returned from GetExcludedCommitCharacters.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, currently it could be called twice for a given session to support import completion, as we might delay those unimported item to a second batch w/o showing them until the list refreshed, or if import completion is disabled, it can be explicitly triggered by using the expander button during the same session. Which means the values in the map from before vs after are from two disjoint sets of completion items (unimported item vs everything else).

{
excludedCommitCharacters = excludedCommitCharacters.Union(excludedCommitCharactersBefore).ToImmutableArray();
foreach (var item in kvp.Value)
{
excludedCommitCharactersMap.Add(kvp.Key, item);
}
}

session.Properties[ExcludedCommitCharacters] = excludedCommitCharacters;
}
else
{
excludedCommitCharactersMap = excludedCommitCharactersFromList;
}

session.Properties[ExcludedCommitCharactersMap] = excludedCommitCharactersMap;
session.Properties[ExcludedCommitCharacters] = excludedCommitCharactersMap.Keys.ToImmutableArray();

// We need to remember the trigger location for when a completion service claims expanded items are available
// since the initial trigger we are able to get from IAsyncCompletionSession might not be the same (e.g. in projection scenarios)
Expand Down Expand Up @@ -564,9 +573,13 @@ private VSCompletionItem Convert(
return item;
}

private static ImmutableArray<char> GetExcludedCommitCharacters(IReadOnlyList<RoslynCompletionItem> roslynItems)
/// <summary>
/// Build a map from added filter characters to corresponding items.
/// CommitManager needs this information to decide whether it should commit selected item.
/// </summary>
private static MultiDictionary<char, RoslynCompletionItem> GetExcludedCommitCharacters(IReadOnlyList<RoslynCompletionItem> roslynItems)
{
var hashSet = new HashSet<char>();
var map = new MultiDictionary<char, RoslynCompletionItem>();
foreach (var roslynItem in roslynItems)
{
foreach (var rule in roslynItem.Rules.FilterCharacterRules)
Expand All @@ -575,13 +588,13 @@ private static ImmutableArray<char> GetExcludedCommitCharacters(IReadOnlyList<Ro
{
foreach (var c in rule.Characters)
{
hashSet.Add(c);
map.Add(c, roslynItem);
}
}
}
}

return hashSet.ToImmutableArray();
return map;
}

internal static bool QuestionMarkIsPrecededByIdentifierAndWhitespace(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12102,5 +12102,86 @@ class Derived : BaseClass
Await state.AssertCompletionItemsContainAll("StaticMember", "InstanceMember")
End Using
End Function

<WorkItem("https://github.com/dotnet/roslyn/issues/70732")>
<WpfFact, Trait(Traits.Feature, Traits.Features.Completion)>
Public Async Function TypingCommitCharWhichIsAlsoFilterCharOfAnotherNoMatchingItemShouldCommit() As Task
Using state = TestStateFactory.CreateCSharpTestState(
<Document>
namespace A
{
public struct PointF
{

public float Y { get; set; }

public float X { get; set; }

public static int operator +(PointF pt, PointF sz) => 0;

public static int operator -(PointF ptA, PointF ptB) => 0;

public static bool operator ==(PointF ptA, PointF ptB) => true;

public static bool operator !=(PointF ptA, PointF ptB) => true;
}

class Program
{
static void Main()
{
PointF point;
point$$
}
}
} </Document>)

state.SendTypeChars(".x")
Await state.AssertSelectedCompletionItem(displayText:="X", isHardSelected:=True)
state.SendTypeChars("+")
Await state.AssertNoCompletionSession()
Assert.Contains("point.X+", state.GetLineTextFromCaretPosition(), StringComparison.Ordinal)
End Using
End Function

<WorkItem("https://github.com/dotnet/roslyn/issues/70732")>
<WpfFact, Trait(Traits.Feature, Traits.Features.Completion)>
Public Async Function TypingCommitCharWhichIsAlsoFilterCharOfAnotherPotentiallyMatchingItemShouldNotCommit() As Task
Using state = TestStateFactory.CreateCSharpTestState(
<Document>
namespace A
{
public struct PointF
{

public float Y { get; set; }

public float X { get; set; }

public static int operator +(PointF pt, PointF sz) => 0;

public static int operator -(PointF ptA, PointF ptB) => 0;

public static bool operator ==(PointF ptA, PointF ptB) => true;

public static bool operator !=(PointF ptA, PointF ptB) => true;
}

class Program
{
static void Main()
{
PointF point;
point$$
}
}
} </Document>)

state.SendTypeChars(".+")
Await state.AssertSelectedCompletionItem(displayText:="+", inlineDescription:="x + y")
state.SendTab()
Assert.Contains("point +", state.GetLineTextFromCaretPosition(), StringComparison.Ordinal)
End Using
End Function
End Class
End Namespace
Loading