Skip to content

Commit

Permalink
Use LSP snippets to specify cursor location for completion
Browse files Browse the repository at this point in the history
  • Loading branch information
dibarbet committed Mar 31, 2022
1 parent 0bdf1c9 commit 81243d8
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,19 @@ public CompletionHandler(
foreach (var item in list.Items)
{
var completionItemResolveData = supportsCompletionListData ? null : completionResolveData;
var lspCompletionItem = await CreateLSPCompletionItemAsync(
request, document, item, completionItemResolveData, lspVSClientCapability, commitCharactersRuleCache,
completionService, snippetsSupported, itemDefaultsSupported, stringBuilder, documentText,
defaultSpan, cancellationToken).ConfigureAwait(false);

var lspCompletionItem = lspVSClientCapability ? new LSP.VSInternalCompletionItem() : new LSP.CompletionItem();

// Add basic properties like display text, filter text, icons, etc.
AddRoslynItemProperties(lspCompletionItem, item, stringBuilder, completionItemResolveData);

// Convert the roslyn edits to LSP text edits using the default item span where possible.
await AddTextEditsAsync(lspCompletionItem, item, document, documentText, completionService, snippetsSupported,
itemDefaultsSupported, list.Span, defaultSpan, cancellationToken).ConfigureAwait(false);

// Convert the roslyn commit character rules to LSP commit characters.
AddCommitCharacters(lspCompletionItem, item, commitCharactersRuleCache, lspVSClientCapability);

lspCompletionItems.Add(lspCompletionItem);
}

Expand Down Expand Up @@ -176,97 +185,66 @@ bool IsValidTriggerCharacterForDocument(Document document, char triggerCharacter
return true;
}

static async Task<LSP.CompletionItem> CreateLSPCompletionItemAsync(
LSP.CompletionParams request,
Document document,
CompletionItem item,
CompletionResolveData? completionResolveData,
bool supportsVSExtensions,
Dictionary<ImmutableArray<CharacterSetModificationRule>, string[]> commitCharacterRulesCache,
CompletionService completionService,
bool snippetsSupported,
bool itemDefaultsSupported,
StringBuilder stringBuilder,
SourceText documentText,
TextSpan defaultSpan,
CancellationToken cancellationToken)
static void AddRoslynItemProperties(LSP.CompletionItem lspItem, CompletionItem roslynItem, StringBuilder stringBuilder, CompletionResolveData? completionResolveData)
{
// Generate display text
stringBuilder.Append(item.DisplayTextPrefix);
stringBuilder.Append(item.DisplayText);
stringBuilder.Append(item.DisplayTextSuffix);
stringBuilder.Append(roslynItem.DisplayTextPrefix);
stringBuilder.Append(roslynItem.DisplayText);
stringBuilder.Append(roslynItem.DisplayTextSuffix);
var completeDisplayText = stringBuilder.ToString();
stringBuilder.Clear();

var completionItem = supportsVSExtensions ? new LSP.VSInternalCompletionItem() : new LSP.CompletionItem();
completionItem.Label = completeDisplayText;
completionItem.SortText = item.SortText;
completionItem.FilterText = item.FilterText;
completionItem.Kind = GetCompletionKind(item.Tags);
completionItem.Data = completionResolveData;
completionItem.Preselect = ShouldItemBePreselected(item);
lspItem.Label = completeDisplayText;
lspItem.SortText = roslynItem.SortText;
lspItem.FilterText = roslynItem.FilterText;
lspItem.Kind = GetCompletionKind(roslynItem.Tags);
lspItem.Data = completionResolveData;
lspItem.Preselect = ShouldItemBePreselected(roslynItem);

if (lspItem is LSP.VSInternalCompletionItem vsCompletionItem)
{
vsCompletionItem.Icon = new ImageElement(roslynItem.Tags.GetFirstGlyph().GetImageId());
}
}

static async Task AddTextEditsAsync(
LSP.CompletionItem lspItem,
CompletionItem roslynItem,
Document document,
SourceText documentText,
CompletionService completionService,
bool snippetsSupported,
bool itemDefaultsSupported,
TextSpan listSpan,
TextSpan defaultTextEditSpan,
CancellationToken cancellationToken)
{
// Complex text edits (e.g. override and partial method completions) are always populated in the
// resolve handler, so we leave both TextEdit and InsertText unpopulated in these cases.
if (item.IsComplexTextEdit && completionItem is LSP.VSInternalCompletionItem vsItem)
if (roslynItem.IsComplexTextEdit && lspItem is LSP.VSInternalCompletionItem vsItem)
{
vsItem.VsResolveTextEditOnCommit = true;
// Razor C# is currently the only language client that supports LSP.InsertTextFormat.Snippet.
// We can enable it for regular C# once LSP is used for local completion.
if (snippetsSupported)
{
completionItem.InsertTextFormat = LSP.InsertTextFormat.Snippet;
lspItem.InsertTextFormat = LSP.InsertTextFormat.Snippet;
}
}
else
{
await AddTextEdit(
document, item, completionItem, completionService, documentText, defaultSpan, itemDefaultsSupported, cancellationToken).ConfigureAwait(false);
await CompletionResolveHandler.AddTextEditAsync(
lspItem, document, documentText, completionService, roslynItem, snippetsSupported, listSpan, itemDefaultsSupported ? defaultTextEditSpan : null, cancellationToken).ConfigureAwait(false);
}
}

var commitCharacters = GetCommitCharacters(item, commitCharacterRulesCache, supportsVSExtensions);
static void AddCommitCharacters(LSP.CompletionItem lspItem, CompletionItem roslynItem,
Dictionary<ImmutableArray<CharacterSetModificationRule>, string[]> commitCharacterRulesCache, bool supportsVSExtensions)
{
var commitCharacters = GetCommitCharacters(roslynItem, commitCharacterRulesCache, supportsVSExtensions);
if (commitCharacters != null)
{
completionItem.CommitCharacters = commitCharacters;
}

if (completionItem is LSP.VSInternalCompletionItem vsCompletionItem)
{
vsCompletionItem.Icon = new ImageElement(item.Tags.GetFirstGlyph().GetImageId());
}

return completionItem;

static async Task AddTextEdit(
Document document,
CompletionItem item,
LSP.CompletionItem lspItem,
CompletionService completionService,
SourceText documentText,
TextSpan defaultSpan,
bool itemDefaultsSupported,
CancellationToken cancellationToken)
{
var completionChange = await completionService.GetChangeAsync(
document, item, cancellationToken: cancellationToken).ConfigureAwait(false);
var completionChangeSpan = completionChange.TextChange.Span;
var newText = completionChange.TextChange.NewText ?? "";

if (itemDefaultsSupported && completionChangeSpan == defaultSpan)
{
// The span is the same as the default, we just need to store the new text as
// the insert text so the client can create the text edit from it and the default range.
lspItem.InsertText = newText;
}
else
{
var textEdit = new LSP.TextEdit()
{
NewText = newText,
Range = ProtocolConversions.TextSpanToRange(completionChangeSpan, documentText),
};
lspItem.TextEdit = textEdit;
}
lspItem.CommitCharacters = commitCharacters;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -11,6 +12,7 @@
using Microsoft.CodeAnalysis.LanguageServer.Handler.Completion;
using Microsoft.CodeAnalysis.LanguageServices;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Text.Adornments;
using Newtonsoft.Json.Linq;
using Roslyn.Utilities;
Expand Down Expand Up @@ -93,12 +95,10 @@ public CompletionResolveHandler(IGlobalOptionService globalOptions, CompletionLi
if (selectedItem.IsComplexTextEdit)
{
Contract.ThrowIfTrue(completionItem.InsertText != null);
Contract.ThrowIfTrue(completionItem.TextEdit != null);

var snippetsSupported = context.ClientCapabilities.TextDocument?.Completion?.CompletionItem?.SnippetSupport ?? false;

completionItem.TextEdit = await GenerateTextEditAsync(
document, completionService, selectedItem, snippetsSupported, cancellationToken).ConfigureAwait(false);
var documentText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
await AddTextEditAsync(completionItem, document, documentText, completionService, selectedItem, snippetsSupported, list.Span, itemDefaultSpan: null, cancellationToken).ConfigureAwait(false);
}

return completionItem;
Expand Down Expand Up @@ -126,20 +126,28 @@ private static bool MatchesLSPCompletionItem(LSP.CompletionItem lspCompletionIte
return string.Equals(originalDisplayText, completionItem.DisplayText);
}

// Internal for testing
internal static async Task<LSP.TextEdit> GenerateTextEditAsync(
internal static async Task AddTextEditAsync(
LSP.CompletionItem lspItem,
Document document,
SourceText documentText,
CompletionService completionService,
CompletionItem selectedItem,
bool snippetsSupported,
TextSpan listSpan,
TextSpan? itemDefaultSpan,
CancellationToken cancellationToken)
{
var documentText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);

var completionChange = await completionService.GetChangeAsync(
document, selectedItem, cancellationToken: cancellationToken).ConfigureAwait(false);
var completionChangeSpan = completionChange.TextChange.Span;
var newText = completionChange.TextChange.NewText;

// Use CompletionChange.TextChanges so that we can get minimal edits around the cursor for better filtering.
// For the rest of the edits that are not around the cursor, classify them as additional edits.
var mainEdit = completionChange.TextChanges.Single(change => change.Span.IntersectsWith(listSpan));
var additionalEdits = completionChange.TextChanges.Remove(mainEdit);

var completionChangeSpan = mainEdit.Span;
var newText = mainEdit.NewText;
var editFormat = LSP.InsertTextFormat.Plaintext;
Contract.ThrowIfNull(newText);

// If snippets are supported, that means we can move the caret (represented by $0) to
Expand All @@ -159,17 +167,37 @@ private static bool MatchesLSPCompletionItem(LSP.CompletionItem lspCompletionIte
if (relativeCaretPosition >= 0 && relativeCaretPosition <= newText.Length)
{
newText = newText.Insert(relativeCaretPosition, "$0");
editFormat = LSP.InsertTextFormat.Snippet;
}
}
}

var textEdit = new LSP.TextEdit()
if (itemDefaultSpan != null && completionChangeSpan == itemDefaultSpan)
{
// The span is the same as the default, we just need to store the new text as
// the insert text so the client can create the text edit from it and the default range.
lspItem.InsertText = newText;
}
else
{
var textEdit = new LSP.TextEdit
{
NewText = newText,
Range = ProtocolConversions.TextSpanToRange(completionChangeSpan, documentText),
};
lspItem.TextEdit = textEdit;
}

if (!additionalEdits.IsEmpty)
{
NewText = newText,
Range = ProtocolConversions.TextSpanToRange(completionChangeSpan, documentText),
};
lspItem.AdditionalTextEdits = additionalEdits.Select(edit => new LSP.TextEdit
{
NewText = edit.NewText ?? string.Empty,
Range = ProtocolConversions.TextSpanToRange(edit.Span, documentText)
}).ToArray();
}

return textEdit;
lspItem.InsertTextFormat = editFormat;
}

private CompletionListCache.CacheEntry? GetCompletionListCacheEntry(LSP.CompletionItem request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,16 @@ class B : A
var document = testLspServer.GetCurrentSolution().Projects.First().Documents.First();

var selectedItem = CodeAnalysis.Completion.CompletionItem.Create(displayText: "M");
var textEdit = await CompletionResolveHandler.GenerateTextEditAsync(
document, new TestCaretOutOfScopeCompletionService(), selectedItem, snippetsSupported: true, CancellationToken.None).ConfigureAwait(false);
var documentText = await document.GetTextAsync(CancellationToken.None).ConfigureAwait(false);
var lspItem = new LSP.CompletionItem();
await CompletionResolveHandler.AddTextEditAsync(
lspItem, document, documentText, new TestCaretOutOfScopeCompletionService(),
selectedItem, snippetsSupported: true, TextSpan.FromBounds(77, 77), itemDefaultSpan: null, CancellationToken.None).ConfigureAwait(false);

Assert.Equal(@"public override void M()
{
throw new System.NotImplementedException();
}", textEdit.NewText);
}", lspItem.TextEdit.NewText);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public class CompletionTests : AbstractLanguageServerProtocolTests
CompletionListSetting = new LSP.CompletionListSetting
{
ItemDefaults = new string[] { CompletionHandler.EditRangeSetting }
},
CompletionItem = new LSP.CompletionItemSetting
{
SnippetSupport = true,
}
}
}
Expand Down Expand Up @@ -1361,6 +1365,33 @@ void M()
Assert.Empty(results.Items);
}

[Fact]
public async Task TestCrefCompletionIncludesCaretPosition()
{
var markup =
@"class A
{
/// <summary>
/// <se{|caret:|}
/// </summary>
void M()
{
}
}";
using var testLspServer = await CreateTestLspServerAsync(markup, s_vsCompletionCapabilities);
var completionParams = CreateCompletionParams(
testLspServer.GetLocations("caret").Single(),
invokeKind: LSP.VSInternalCompletionInvokeKind.Explicit,
triggerCharacter: "\0",
triggerKind: LSP.CompletionTriggerKind.Invoked);

var document = testLspServer.GetCurrentSolution().Projects.First().Documents.First();

var result = await RunGetCompletionsAsync(testLspServer, completionParams).ConfigureAwait(false);
Assert.Equal("<see cref=\"$0\"/>", result.Items.First().InsertText);
Assert.Equal(LSP.InsertTextFormat.Snippet, result.Items.First().InsertTextFormat);
}

internal static Task<LSP.CompletionList> RunGetCompletionsAsync(TestLspServer testLspServer, LSP.CompletionParams completionParams)
{
return testLspServer.ExecuteRequestAsync<LSP.CompletionParams, LSP.CompletionList>(LSP.Methods.TextDocumentCompletionName,
Expand Down

0 comments on commit 81243d8

Please sign in to comment.