vs-editor-api
vs-editor-api copied to clipboard
Async Completion API discussion
Async Completion API walkthrough
Please comment 🐱👤, and I will add emoji to your comment depending on my progress 👍 I will do it 🎉 Done
Table of contents:
- Basics
- When completion is available
- How to implement a language service that participates in async completion API
- How to extend a language with new completion items
- How to implement custom sorting and filtering
- How to implement the UI
- How to interact with completion
- Best practices
Basics
The API is defined in Microsoft.VisualStudio.Language
nuget, in the Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion
namespace.
All helper types are defined in Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data
namespace.
Completion in VS uses MEF to discover extensions. Specifically, VS is looking for exports of type IAsyncCompletionSourceProvider
, IAsyncCompletionItemManagerProvider
, IAsyncCompletionCommitManagerProvider
and ICompletionPresenterProvider
. Each export must be decorated with Name
and ContentType
metadata. It may be decorated with TextViewRoles
and Order
metadata.
In general, methods named ...Async
are invoked on the background thread and are cancelable. Otherwise, methods are invoked on the UI thread.
When completion is available
Prior to starting, completion checks the following:
-
IFeatureService.IsEnabled(PredefinedEditorFeatureNames.Completion)
-
IFeatureService
allows features to be disabled per view or per application -
IFeatureService
allows a group of features to be disabled, e.g. "disable all popups" - Current and planned uses:
- Inline Rename, for the duration of rename session
- Multi Caret, until the two features are certified to work together
- ReSharper, because their hacks to disable a feature don't work with the asynchronous nature of Async Completion
-
-
Is flight
"CompletionAPI"
enabled inIVsExperimentationService
-
IAsyncCompletionBroker.IsCompletionSupported
checks if there are any-
IAsyncCompletionSourceProvider
s and -
IAsyncCompletionItemManager
s for a target content type
-
How to implement a language service that participates in async completion API
IAsyncCompletionSource
Implement IAsyncCompletionSourceProvider that returns an instance of IAsyncCompletionSource
When user interacts with Visual Studio, e.g. by typing, the editor will see if it is appropriate to begin a completion session. We do so by calling TryGetApplicableSpan. This method is invoked on UI thread while the user is typing, therefore it is important to return promptly. Usually, you just need to make a syntactic check whether completion is appropriate at the given location. Despite being called on the UI thread, we a supply CancellationToken
that you should check and respect.
If at least one IAsyncCompletionSource
returned true
from TryGetApplicableSpan
, completion session will start and begin processing on the background thread.
We will attempt to get completion items by asynchronously calling GetCompletionContextAsync where you provide completion items.
This method will be called on all available IAsyncCompletionSource
s, even if they returned false
from TryGetApplicableSpan
.
This is to accommodate for extensions who wish to add completion items without the need to care about the language's syntax, and potential interference with the main language service.
Items from all sources will be combined and eventually displayed in the UI. The UI will call GetDescriptionAsync to build tooltips for items you provided.
IAsyncCompletionCommitManager
Implement IAsyncCompletionCommitManagerProvider that returns an instance of IAsyncCompletionCommitManager
We use this interface to determine under which circumstances to commit (insert the completion text into the text buffer and close the completion UI) a completion item.
There must be one IAsyncCompletionCommitManager
available to begin completion session.
When we first create the completion session, we access the PotentialCommitCharacters property that returns characters that potentially commit completion when user types them. We access this property, therefore it should return a preallocated array.
Typically, the commit characters include space and other token delimeters such as .
, (
, )
. Don't worry about Tab and Enter, as they are handled separately. If a character is a commit character in some, but not all situations, you must add it to this collection. Characters from available IAsyncCompletionCommitManager
s are combined into a single collection for the duration of the completion session.
We maintain this list so that editor's completion feature can quickly ignore characters that are not commit characters. If user types a character found in the provided array, Editor will call ShouldCommitCompletion on the UI thread. This is an opportunity to tell whether certain character is indeed a commit character in the given location. In most cases, simply return true
, which means that every character in PotentialCommitCharacters
will trigger the commit behavior.
When the completion item is about to be committed, Editor calls TryCommit on available IAsyncCompletionCommitManager
s. This method is also called on UI thread and offers complete access to the ITextView
and ITextBuffer
, so that the language service can customize the way text is entered into the buffer. This method returns CommitResult which provides two pieces of information:
-
bool
that indicates whether the item was committed - if not, Editor will callTryCommit
on anotherIAsyncCompletionCommitManager
-
CommitBehavior with instructions for how to proceed. This is used by complicated language services, and it's best to return
None
.
Speaking of complicated language services - TryCommit
was written for these language services. In most cases, feel free to return CommitResult.Unhandled. When all IAsyncCompletionCommitManager
s return CommitResult.Unhandled
, Editor will simply insert the completion item into the text buffer in the appropriate location.
How to extend a language with new completion items
The async completion API allows you to create extension that adds new completion items, without concerning you with the syntax tree or how to commit the item. These questions will be delegated to the language service. You just need to implement IAsyncCompletionSourceProvider that returns an instance of IAsyncCompletionSource
When you implement TryGetApplicableSpan, return false and leave applicableSpan
as default
. As long as a single language service returns true and sets the applicableSpan
, completion will start. Returning false
does not exclude you from participating in completion!
Implement GetCompletionContextAsync where you provide completion items. They will be added to items from other sources. Implement GetDescriptionAsync that will provide tooltips for items you provided.
How to implement custom sorting and filtering
Visual Studio provides standard sorting and filtering facilities, but you may want to provide custom behavior for ContentType and TextViewRoles of your choice.
Implement IAsyncCompletionItemManagerProvider that returns an instance of IAsyncCompletionItemManager. Decorate IAsyncCompletionItemManagerProvider
with MEF metadata ContentType
and optionally TextViewRoles
to narrow the scope of your extension.
The completion feature in Visual Studio is represented by IAsyncCompletionSession
(we'll now call it "Session"). This object is active from the moment completion is "triggered" (see TryGetApplicableSpan) until it is Dismissed (pressing Escape or clicking away from completion UI) or until a completion item is Commited. The Session object holds properties that don't drastically change throughout the lifetime of the session: it stores the ITrackingSpan
where completion is happening, has a reference to the ITextView
(which may be null in Cascade!) and has a PropertyBag
.
Immediately after obtaining CompletionItem
s from IAsyncCompletionSource
s, the Session calls SortCompletionListAsync. Then, the Session calls UpdateCompletionListAsync and will do so as long as the user is typing. Both methods are called asynchronously and take similar paremeters which we will cover shortly.
The purpose of SortCompletionListAsync
is to sort items we received from multiple IAsyncCompletionSource
s.
UpdateCompletionListAsync
will receive this sorted list. This is merely a performance improvement so that UpdateCompletionListAsync
doesn't need to sort.
The purpose of UpdateCompletionListAsync
is to produce a final list of items to display in the UI, and to provide information on how to display items in the UI. All the information is bundled in FilteredCompletionModel
Important:
Let's go over the asynchronous computation model.
Suppose user is continuously typing. At every keystroke, we take a reference to the text snapshot in the editor. If we have time, we call UpdateCompletionListAsync
. If user typed quickly, we won't call UpdateCompletionListAsync
. Or perhaps user typed (and modified editor's contents) while you were processing UpdateCompletionListAsync
.
This means that information from IAsyncCompletionSession
or its reference to ITextView
may be stale. This is why we maintain an immutable model that stores all important properties of the completion session. We select the relevant bits and put them into second parameter of SortCompletionListAsync
and UpdateCompletionListAsync
: SessionInitialData and SessionInstantenousData respectively.
We introduced these data transfer objects to keep method signatures short.
How to implement the UI
Visual Studio provides standard UI, but you may want to create a custom UI for specific ContentType or TextViewRoles. Decorate ICompletionPresenterProvider
with MEF metadata to narrow the scope for your UI.
Implement ICompletionPresenterProvider that returns an instance of ICompletionPresenter
This interface represents a class that manages the user interface. When we first show the completion UI, we call the Open method, and subsequently we call the Update method. Both methods accept a single parameter of type CompletionPresentationViewModel which contains data required to render the UI. We call these methods on the UI thread.
Notice that completion items are represented by CompletionItemWithHighlight - a struct that combines CompletionItem
and array of Span
s that should be bolded in the UI.
Completion filters are represented by CompletionFilterWithState that combines CompletionFilter
with two bools that indicate whether filter is available and whether it is selected by the user. As the user types and narrows down the list of completion items, they also narrow down list of completion filters. Unavailable completion filters are not associated with any items that are visible at the moment, and we represent them with dim icons.
The CompletionFilter
itself has displayText
that appears in the tooltip, accessKey
which is bound to a keyboard shortcut and image
to represent it in the UI.
When user clicks a filter button, create a new instance of CompletionFilterWithState
by calling CompletionFilterWithState.WithSelected, then raise CompletionFilterChaned
event (TODO: event's documentation is not available). Editor will recompute completion items and call Update
with new data.
When user changes the selected item by clicking on it, call CompletionItemSelected event (TODO: event's documentation is not available)
Handling of Up, Down, Page Up and Page Down keys is done on the Editor's side, the UI should not handle these cases. When handling Page Up and Page Down keys, we use the ICompletionPresenterProvider.ResultsPerPage property to select appropriate item.
The completion session depends on the ICompletionPresenter
to accurately report the state of the UI using the following events:
-
FiltersChanged
that computes new set of items to display after user changed completion filters -
CompletionItemSelected
that updates the selected item after user clicked it -
CommitRequested
when user double-clicked an item -
CompletionClosed
when the UI is closed.
How to interact with completion
IAsyncCompletionBroker is the entry point to the completion feature:
- method TriggerCompletion is used by VS to trigger a new session
- method IsCompletionActive lets you can see if there is an active session in a given
ITextView
- method GetSession gets the active session in a given
ITextView
or returns null. - method IsCompletionSupported returns whether there are any available
IAsyncCompletionSourceProvider
s for a givenIContentType
.
IAsyncCompletionSession exposes the following:
- property
TextView
which is a reference to pertinent text view- note its
ITextSnapshot
may be different from what's used during computation. See SessionInstantenousData for the correctITextSnapshot
.
- note its
- property
ApplicableSpan
which tracks span- Whose content is used to filter completion
- That will be replaced by committed item
- event
CompletionTriggered
when completion session is triggered - event
ItemCommitted
when completion commits and closes, - event
Dismissed
when completion closes without committing. -
GetComputedItems
method that blocks to finish computation and returnsComputedCompletionItems
- event
ItemsUpdated
that retrunsComputedCompletionItems
after computation finished.
The ComputedCompletionItems
, available from IAsyncCompletionSession.ItemsUpdated
and IAsyncCompletionSession.GetComputedItems()
stores:
- Items
- Suggestion item
- Currently selected item
- Whether the selected item is a suggestion item
Best practices
Best practices for completion item source
To minimize number of allocations, create icons and filters once, and use their references in CompletionItem
. For example:
static readonly ImageElement PropertyImage = new AccessibleImageElement(KnownMonikers.Property.ToImageId(), "Property image");
static readonly CompletionFilter PropertyFilter = new CompletionFilter("Properties", "P", PropertyImage);
static readonly ImmutableArray<CompletionFilter> PropertyFilters = new CompletionFilter[] { PropertyFilter }.ToImmutableArray();
Given that IAsyncCompletionSource.TryGetApplicableSpan() is called on UI thread and can potentially affect typing perf it should at least provide a cancellation token, which you can hookup to the commanding cancellation or setup your own wait dialog if it's called outside of command execution.
Up to date list of things that are not in the API:
-
IAsyncCompletionSession
event that the selection changed. We fire an event when items were updated, but not when scrolling. - Exposing the UI presenter to extenders. All communication with the UI is between internally selected presenter and the implementation of
IAsyncCompletionSession
Please document a recommended way to provide UI in GetDescriptionAsync(). I think it's going to be pretty popular question.
Please clarify if IAsyncCompletionCommitManager is required or optional.
Overall 🥇
One thing I think is cause for concern is how we choose whether or not the new completion API is supported.
IAsyncCompletionBroker.IsCompletionSupported checks if there are any IAsyncCompletionSourceProviders and IAsyncCompletionItemManagers for a target content type
I don't think this logic is sufficient to prevent extenders from breaking languages that use pre-async completion.
For example, if I create a VS snippets extension that uses async completion and matches content type 'text' with the intention of providing language-agnostic snippets, the IsCompletionSupported() method will return true for all languages, because there is a provider that matches the content type.
This will work fine for language services that onboard to the new API, but for any language that hasn't moved (and likely won't move) over to the new API, like XML, they will get only my extensions completion items, and their language-specific completion will be broken.
The same is true in multi-language or embedded language scenarios. The embedder and the embeddee will have to use the same completion API or else we'll only show items for whichever uses the new API. For Web tools, we can get a commitment to standardize on the new APIs, but for other languages, like P#, which embed code from language services that will be on the new APIs (C#), this will be a breaking change.
As unsightly as shims are, this is the same manner of problem we had with quick info and being able to display content from different API generations in the same tip. Without a reasonable shim, I think we'll get a lot of negative feedback from extenders. At the very least, there might be value in having a way to annotate a provider as being important enough to trigger the new completion to be used so that the things like the hypothetical snippets provider don't trigger the new completion on languages it isn't supported by.
Probably unnecessary to mention, but just in case...
Some of the API has changed, but isn't updated here.
ie, TryGetApplicableSpan
-> TryGetApplicableToSpan
that's correct. There will a few more minor changes coming tonight. I'm sorry for inconvenience and will try to update this guide when I have a moment
I’ve made some changes to the completion API and would like to seek partner signoff.
Notably, most methods of the API now have a reference to IAsyncCompletionSession, so that you can share data through the property bag more often. Note that the property bag isn’t always safe due to asynchronous nature of the implementation.
I believe that I’ve addressed some issues raised by @CyrusNajmabadi except for one:
Cyrus proposed that we expose “method of committing” – to distinguish whether user clicked, hit Ctrl+Space etc. What’s the user scenario for having different commit on Ctrl+Space than click\enter\tab? We would like to accommodate partner use cases, but also we don't want to confuse user by varying commit behavior based on method of committing.
Open the attached dll may be opened using ILSpy to see the interface methods and their doc comments:
What’s the user scenario for having different commit on Ctrl+Space than click\enter\tab?
Historical reasons. For example, Roslyn has specific
We need to know how things were triggered to properly support that.
Note that VB and C# also have different defaults here (again due to historical behavior that these different teams had for decades).
Thanks, Cyrus. That's a good point.
Do we have special behavior on click or Ctrl+Space?
Enter can be detected through TypeChar \n
.
As far as what triggered the completion goes, IAsyncCompletionSource may put the initial CompletionTrigger
into the property bag which would be accessed in TryCommit
@dpoeschl @ivanbasov can you comment on feasability of the solutions I proposed to Cyrus' point?
Do we have special behavior on click or Ctrl+Space?
we have different behavior on ctrl-space vs ctrl-j (i.e. commit unique, or bring up completino).
Enter can be detected through TypeChar \n.
That feels enormously hacky. :)
But i get it shipped, and can work that way. so while i don't like it, i get taht it's likely the most expediant choice.
How does one know if the item was inserted due to ctrl-space vs due to normal space?
How does one know if the item was inserted due to ctrl-space vs due to normal space?
When we call IAsyncCompletionItemSource.GetCompletionContextAsync
we pass in the initial CompletionTrigger
that has an property of enumerated type CompletionTriggerReason
.
For Ctrl+Space it would be InvokeAndCommitIfUnique
, for typing it would be Insertion
and for Ctrl+J it would be Invoke
.
However, if the completion was already visible because of, for example, typing, and then user hit Ctrl+Space, the "initial trigger" would remain what it originally was. There is no way to tell that item is committed by Ctrl+Space.
What's the user scenario that's on your mind?
What's the user scenario that's on your mind?
So Roslyn has the model that you can bring up completion (with something like ctrl-j or ctrl-space). Then, once completion is up you can optionally type more, and then hit ctrl-space again. Ctrl-space should now commit if the item is unique.
Is that behavior preserved with the new system?
Absolutely, this is preserved:
If there is no unique item, first Ctrl+Space will open completion and user has opportunity to refine the results.
What you can't do is know that user pressed Ctrl+Space in your code that handles commit. The purpose of the code is to modify a provided ITextBuffer
using a provided CompletionItem
My question is whether there is any user scenario, in which the code that handles commit needs to know about how user caused item to get committed (e.g. click or Ctrl+Space)
Looking! :)
My question is whether there is any user scenario, in which the code that handles commit needs to know about how user caused item to get committed (e.g. click or Ctrl+Space)
It looks like if you double-click the item we end up calling through teh same codepath that is used for "commit if unique" (when we know the item is unique).
So click vs control-space should always be the same.
Another thing to check: Roslyn completion items have the concept of 'absorbing' the typed character or not. i.e. for some characters (like tab) they will eat the character and not send into the buffer. however a character like ';' will be sent through.
It's feature dependent as some features want to control heavily exactly what actually makes it into the buffer.
That's still supported, right?
Thanks!
Yes, as far as Roslyn is concerned, there is the same code path for committing through double click, ctrl+space. Enter, Tab will additionally pass in typeChar for editing purposes
Yes, Roslyn may pass CommitBehavior:
/// <summary>
/// Use the default behavior,
/// that is, to propagate TypeChar command, but surpress ReturnKey and TabKey commands.
/// </summary>
None = 0b0000,
/// <summary>
/// Surpresses further invocation of the TypeChar command handlers.
/// By default, editor invokes these command handlers to enable features such as brace completion.
/// </summary>
SuppressFurtherTypeCharCommandHandlers = 0b0001,
/// <summary>
/// Raises further invocation of the ReturnKey and Tab command handlers.
/// By default, editor doesn't invoke ReturnKey and Tab command handlers after committing completion session.
/// </summary>
RaiseFurtherReturnKeyAndTabKeyCommandHandlers = 0b0010,
/// <summary>
/// Cancels the commit operation, does not call any other <see cref="IAsyncCompletionCommitManager.TryCommit(IAsyncCompletionSession, Text.ITextBuffer, CompletionItem, char, System.Threading.CancellationToken)"/>.
/// Functionally, acts as if the typed character was not a commit character,
/// allowing the user to continue working with the <see cref="IAsyncCompletionSession"/>
/// </summary>
CancelCommit = 0b0100,
Greatr. thanks!
In the previous version of the API, TryCommit had two triggers: initial trigger and update trigger. We consumed them a very strange way. With the current API, I just simplified the way we use the trigger. Based on the current test coverage, we may need just the update trigger.
However, if there is a scenario where we need both, let us arrange it as a unit test and then return back the initial trigger.
@ivanbasov if you need the initial trigger, you can store it in the session's property bag in GetCompletionContextAsync. Would this work?
@AmadeusW , thank you! Great idea! I will do this if necessary.