|
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.LanguageServer.Handler.Completion;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Microsoft.VisualStudio.Text.Adornments;
using Newtonsoft.Json.Linq;
using Roslyn.Utilities;
using LSP = Microsoft.VisualStudio.LanguageServer.Protocol;
namespace Microsoft.CodeAnalysis.LanguageServer.Handler
{
/// <summary>
/// Handle a completion resolve request to add description.
///
/// TODO - This must be moved to the MS.CA.LanguageServer.Protocol project once the
/// references to VS icon types and classified text runs are removed.
/// See https://github.com/dotnet/roslyn/issues/55142
/// </summary>
/// <remarks>
/// This isn't a <see cref="ILspServiceDocumentRequestHandler{TRequest, TResponse}" /> because it could return null.
/// </remarks>
[Method(LSP.Methods.TextDocumentCompletionResolveName)]
internal sealed class CompletionResolveHandler : ILspServiceRequestHandler<LSP.CompletionItem, LSP.CompletionItem>, ITextDocumentIdentifierHandler<LSP.CompletionItem, LSP.TextDocumentIdentifier?>
{
private readonly CompletionListCache _completionListCache;
private readonly IGlobalOptionService _globalOptions;
public bool MutatesSolutionState => false;
public bool RequiresLSPSolution => true;
public CompletionResolveHandler(IGlobalOptionService globalOptions, CompletionListCache completionListCache)
{
_globalOptions = globalOptions;
_completionListCache = completionListCache;
}
public LSP.TextDocumentIdentifier? GetTextDocumentIdentifier(LSP.CompletionItem request)
=> GetCompletionListCacheEntry(request)?.TextDocument;
public async Task<LSP.CompletionItem> HandleRequestAsync(LSP.CompletionItem completionItem, RequestContext context, CancellationToken cancellationToken)
{
var document = context.GetRequiredDocument();
var clientCapabilities = context.GetRequiredClientCapabilities();
var completionService = document.Project.Services.GetRequiredService<CompletionService>();
var cacheEntry = GetCompletionListCacheEntry(completionItem);
if (cacheEntry == null)
{
// Don't have a cache associated with this completion item, cannot resolve.
context.TraceInformation("No cache entry found for the provided completion item at resolve time.");
return completionItem;
}
var list = cacheEntry.CompletionList;
// Find the matching completion item in the completion list
var selectedItem = list.ItemsList.FirstOrDefault(cachedCompletionItem => MatchesLSPCompletionItem(completionItem, cachedCompletionItem));
if (selectedItem == null)
{
return completionItem;
}
var completionOptions = _globalOptions.GetCompletionOptions(document.Project.Language);
var displayOptions = _globalOptions.GetSymbolDescriptionOptions(document.Project.Language);
var description = await completionService.GetDescriptionAsync(document, selectedItem, completionOptions, displayOptions, cancellationToken).ConfigureAwait(false)!;
if (description != null)
{
var supportsVSExtensions = clientCapabilities.HasVisualStudioLspCapability();
if (supportsVSExtensions)
{
var vsCompletionItem = (LSP.VSInternalCompletionItem)completionItem;
vsCompletionItem.Description = new ClassifiedTextElement(description.TaggedParts
.Select(tp => new ClassifiedTextRun(tp.Tag.ToClassificationTypeName(), tp.Text)));
}
else
{
var clientSupportsMarkdown = clientCapabilities.TextDocument?.Completion?.CompletionItem?.DocumentationFormat?.Contains(LSP.MarkupKind.Markdown) == true;
completionItem.Documentation = ProtocolConversions.GetDocumentationMarkupContent(description.TaggedParts, document, clientSupportsMarkdown);
}
}
// We compute the TextEdit resolves for complex text edits (e.g. override and partial
// method completions) here. Lazily resolving TextEdits is technically a violation of
// the LSP spec, but is currently supported by the VS client anyway. Once the VS client
// adheres to the spec, this logic will need to change and VS will need to provide
// official support for TextEdit resolution in some form.
if (selectedItem.IsComplexTextEdit)
{
Contract.ThrowIfTrue(completionItem.InsertText != null);
Contract.ThrowIfTrue(completionItem.TextEdit != null);
var snippetsSupported = clientCapabilities?.TextDocument?.Completion?.CompletionItem?.SnippetSupport ?? false;
completionItem.TextEdit = await GenerateTextEditAsync(
document, completionService, selectedItem, snippetsSupported, cancellationToken).ConfigureAwait(false);
}
return completionItem;
}
private static bool MatchesLSPCompletionItem(LSP.CompletionItem lspCompletionItem, CompletionItem completionItem)
{
if (!lspCompletionItem.Label.StartsWith(completionItem.DisplayTextPrefix, StringComparison.Ordinal))
{
return false;
}
// The prefix matches, consume the matching prefix from the lsp completion item label.
var displayTextWithSuffix = lspCompletionItem.Label[completionItem.DisplayTextPrefix.Length..];
if (!displayTextWithSuffix.EndsWith(completionItem.DisplayTextSuffix, StringComparison.Ordinal))
{
return false;
}
// The suffix matches, consume the matching suffix from the lsp completion item label.
var originalDisplayText = displayTextWithSuffix[..^completionItem.DisplayTextSuffix.Length];
// Now we're left with what should be the original display text for the lsp completion item.
// Check to make sure it matches the cached completion item label.
return string.Equals(originalDisplayText, completionItem.DisplayText);
}
// Internal for testing
internal static async Task<LSP.TextEdit> GenerateTextEditAsync(
Document document,
CompletionService completionService,
CompletionItem selectedItem,
bool snippetsSupported,
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;
Contract.ThrowIfNull(newText);
// If snippets are supported, that means we can move the caret (represented by $0) to
// a new location.
if (snippetsSupported)
{
var caretPosition = completionChange.NewPosition;
if (caretPosition.HasValue)
{
// caretPosition is the absolute position of the caret in the document.
// We want the position relative to the start of the snippet.
var relativeCaretPosition = caretPosition.Value - completionChangeSpan.Start;
// The caret could technically be placed outside the bounds of the text
// being inserted. This situation is currently unsupported in LSP, so in
// these cases we won't move the caret.
if (relativeCaretPosition >= 0 && relativeCaretPosition <= newText.Length)
{
newText = newText.Insert(relativeCaretPosition, "$0");
}
}
}
var textEdit = new LSP.TextEdit()
{
NewText = newText,
Range = ProtocolConversions.TextSpanToRange(completionChangeSpan, documentText),
};
return textEdit;
}
private CompletionListCache.CacheEntry? GetCompletionListCacheEntry(LSP.CompletionItem request)
{
Contract.ThrowIfNull(request.Data);
var resolveData = ((JToken)request.Data).ToObject<CompletionResolveData>();
if (resolveData?.ResultId == null)
{
Contract.Fail("Result id should always be provided when resolving a completion item we returned.");
return null;
}
var cacheEntry = _completionListCache.GetCachedEntry(resolveData.ResultId.Value);
if (cacheEntry == null)
{
// No cache for associated completion item. Log some telemetry so we can understand how frequently this actually happens.
Logger.Log(FunctionId.LSP_CompletionListCacheMiss, KeyValueLogMessage.NoProperty);
}
return cacheEntry;
}
}
}
|