/
githubmirror
/
roslyn
Обзор
Документация
Войти
/
githubmirror
/
roslyn
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/LanguageServer/Protocol/Extensions/ProtocolConversions.cs
1 120 строк
49 KB
Joey Robichaud
Handle textDocument/didChange notifications that don't pass across the range (#84714)
03 авг 2026, 08:53
Не верифицирован
03 авг 2026, 08:53
ce65aa8
Код
Авторство
О чём код?
// 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.Buffers; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.DocumentHighlighting; using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.NavigateTo; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.SpellCheck; using Microsoft.CodeAnalysis.Tags; using Microsoft.CodeAnalysis.Text; using Roslyn.LanguageServer.Protocol; using Roslyn.Text.Adornments; using Roslyn.Utilities; using Logger = Microsoft.CodeAnalysis.Internal.Log.Logger; using LSP = Roslyn.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.LanguageServer; internal static partial class ProtocolConversions { private const string CSharpMarkdownLanguageName = "csharp"; private const string VisualBasicMarkdownLanguageName = "vb"; private const string BlockCodeFence = "```"; private const string InlineCodeFence = "`"; private static readonly char[] s_dirSeparators = [PathUtilities.DirectorySeparatorChar, PathUtilities.AltDirectorySeparatorChar]; private static readonly Regex s_markdownEscapeRegex = new(@"([\\`\*_\{\}\[\]\(\)#+\-\.!<>])", RegexOptions.Compiled); // NOTE: While the spec allows it, don't use Function and Method, as both VS and VS Code display them the same // way which can confuse users /// <summary> /// Mapping from tags to lsp completion item kinds. The value lists the potential lsp kinds from /// least-preferred to most preferred. More preferred kinds will be chosen if the client states they support /// it. This mapping allows values including extensions to the kinds defined by VS (but not in the core LSP /// spec). /// </summary> public static readonly ImmutableDictionary<string, ImmutableArray<LSP.CompletionItemKind>> RoslynTagToCompletionItemKinds = new Dictionary<string, ImmutableArray<LSP.CompletionItemKind>>() { { WellKnownTags.Public, ImmutableArray.Create(LSP.CompletionItemKind.Keyword) }, { WellKnownTags.Protected, ImmutableArray.Create(LSP.CompletionItemKind.Keyword) }, { WellKnownTags.Private, ImmutableArray.Create(LSP.CompletionItemKind.Keyword) }, { WellKnownTags.Internal, ImmutableArray.Create(LSP.CompletionItemKind.Keyword) }, { WellKnownTags.File, ImmutableArray.Create(LSP.CompletionItemKind.File) }, { WellKnownTags.Project, ImmutableArray.Create(LSP.CompletionItemKind.File) }, { WellKnownTags.Folder, ImmutableArray.Create(LSP.CompletionItemKind.Folder) }, { WellKnownTags.Assembly, ImmutableArray.Create(LSP.CompletionItemKind.File) }, { WellKnownTags.Class, ImmutableArray.Create(LSP.CompletionItemKind.Class) }, { WellKnownTags.Constant, ImmutableArray.Create(LSP.CompletionItemKind.Constant) }, { WellKnownTags.Delegate, ImmutableArray.Create(LSP.CompletionItemKind.Class, LSP.CompletionItemKind.Delegate) }, { WellKnownTags.Enum, ImmutableArray.Create(LSP.CompletionItemKind.Enum) }, { WellKnownTags.EnumMember, ImmutableArray.Create(LSP.CompletionItemKind.EnumMember) }, { WellKnownTags.Event, ImmutableArray.Create(LSP.CompletionItemKind.Event) }, { WellKnownTags.ExtensionMethod, ImmutableArray.Create(LSP.CompletionItemKind.Method, LSP.CompletionItemKind.ExtensionMethod) }, { WellKnownTags.Field, ImmutableArray.Create(LSP.CompletionItemKind.Field) }, { WellKnownTags.Interface, ImmutableArray.Create(LSP.CompletionItemKind.Interface) }, { WellKnownTags.Intrinsic, ImmutableArray.Create(LSP.CompletionItemKind.Text) }, { WellKnownTags.Keyword, ImmutableArray.Create(LSP.CompletionItemKind.Keyword) }, { WellKnownTags.Label, ImmutableArray.Create(LSP.CompletionItemKind.Text) }, { WellKnownTags.Local, ImmutableArray.Create(LSP.CompletionItemKind.Variable) }, { WellKnownTags.Namespace, ImmutableArray.Create(LSP.CompletionItemKind.Module, LSP.CompletionItemKind.Namespace) }, { WellKnownTags.Method, ImmutableArray.Create(LSP.CompletionItemKind.Method) }, { WellKnownTags.Module, ImmutableArray.Create(LSP.CompletionItemKind.Module) }, { WellKnownTags.Operator, ImmutableArray.Create(LSP.CompletionItemKind.Operator) }, { WellKnownTags.Parameter, ImmutableArray.Create(LSP.CompletionItemKind.Variable) }, { WellKnownTags.Property, ImmutableArray.Create(LSP.CompletionItemKind.Property) }, { WellKnownTags.RangeVariable, ImmutableArray.Create(LSP.CompletionItemKind.Variable) }, { WellKnownTags.Reference, ImmutableArray.Create(LSP.CompletionItemKind.Reference) }, { WellKnownTags.Structure, ImmutableArray.Create(LSP.CompletionItemKind.Struct) }, { WellKnownTags.TypeParameter, ImmutableArray.Create(LSP.CompletionItemKind.TypeParameter) }, { WellKnownTags.Snippet, ImmutableArray.Create(LSP.CompletionItemKind.Snippet) }, { WellKnownTags.Error, ImmutableArray.Create(LSP.CompletionItemKind.Text) }, { WellKnownTags.Warning, ImmutableArray.Create(LSP.CompletionItemKind.Text) }, { WellKnownTags.StatusInformation, ImmutableArray.Create(LSP.CompletionItemKind.Text) }, { WellKnownTags.AddReference, ImmutableArray.Create(LSP.CompletionItemKind.Text) }, { WellKnownTags.NuGet, ImmutableArray.Create(LSP.CompletionItemKind.Text) } }.ToImmutableDictionary(); /// <summary> /// Mapping from tags to LSP completion item tags. The value lists the potential LSP tags from /// least-preferred to most preferred. More preferred kinds will be chosen if the client states they support /// it. This mapping allows values including extensions to the kinds defined by VS (but not in the core LSP /// spec). /// </summary> public static readonly ImmutableDictionary<string, ImmutableArray<LSP.CompletionItemTag>> RoslynTagToCompletionItemTags = new Dictionary<string, ImmutableArray<LSP.CompletionItemTag>>() { { WellKnownTags.Deprecated, ImmutableArray.Create(LSP.CompletionItemTag.Deprecated) }, }.ToImmutableDictionary(); public static JsonSerializerOptions AddLspSerializerOptions(this JsonSerializerOptions options) { LSP.VSInternalExtensionUtilities.AddVSInternalExtensionConverters(options); options.Converters.Add(new LSP.NaturalObjectConverter()); options.Converters.Add(new DocumentUriConverter()); return options; } /// <summary> /// Options that know how to serialize / deserialize basic LSP types. /// Useful when there are particular fields that are not serialized or deserialized by normal request handling (for example /// deserializing a field that is typed as object instead of a concrete type). /// </summary> public static JsonSerializerOptions LspJsonSerializerOptions = new JsonSerializerOptions().AddLspSerializerOptions(); // TO-DO: More LSP.CompletionTriggerKind mappings are required to properly map to Roslyn CompletionTriggerKinds. // https://dev.azure.com/devdiv/DevDiv/_workitems/edit/1178726 public static async Task<Completion.CompletionTrigger> LSPToRoslynCompletionTriggerAsync( LSP.CompletionContext? context, Document document, int position, CancellationToken cancellationToken) { if (context is null) { // Some LSP clients don't support sending extra context, so all we can do is invoke return Completion.CompletionTrigger.Invoke; } else if (context.TriggerKind is LSP.CompletionTriggerKind.Invoked or LSP.CompletionTriggerKind.TriggerForIncompleteCompletions) { if (context is not LSP.VSInternalCompletionContext vsCompletionContext) { return Completion.CompletionTrigger.Invoke; } switch (vsCompletionContext.InvokeKind) { case LSP.VSInternalCompletionInvokeKind.Explicit: return Completion.CompletionTrigger.Invoke; case LSP.VSInternalCompletionInvokeKind.Typing: var insertionChar = await GetInsertionCharacterAsync(document, position, cancellationToken).ConfigureAwait(false); return Completion.CompletionTrigger.CreateInsertionTrigger(insertionChar); case LSP.VSInternalCompletionInvokeKind.Deletion: Contract.ThrowIfNull(context.TriggerCharacter); Contract.ThrowIfFalse(char.TryParse(context.TriggerCharacter, out var triggerChar)); return Completion.CompletionTrigger.CreateDeletionTrigger(triggerChar); default: // LSP added an InvokeKind that we need to support. Logger.Log(FunctionId.LSPCompletion_MissingLSPCompletionInvokeKind); return Completion.CompletionTrigger.Invoke; } } else if (context.TriggerKind is LSP.CompletionTriggerKind.TriggerCharacter) { Contract.ThrowIfNull(context.TriggerCharacter); Contract.ThrowIfFalse(char.TryParse(context.TriggerCharacter, out var triggerChar)); return Completion.CompletionTrigger.CreateInsertionTrigger(triggerChar); } else { // LSP added a TriggerKind that we need to support. Logger.Log(FunctionId.LSPCompletion_MissingLSPCompletionTriggerKind); return Completion.CompletionTrigger.Invoke; } // Local functions static async Task<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken cancellationToken) { var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false); // We use 'position - 1' here since we want to find the character that was just inserted. Contract.ThrowIfTrue(position < 1); var triggerCharacter = text[position - 1]; return triggerCharacter; } } public static bool IsSourceGeneratedScheme(string scheme) { return scheme == SourceGeneratedDocumentUri.Scheme; } /// <summary> /// Converts an absolute local file path or an absolute URL string to <see cref="Uri"/>. /// </summary> /// <exception cref="UriFormatException"> /// The <paramref name="absolutePath"/> can't be represented as <see cref="Uri"/>. /// For example, UNC paths with invalid characters in server name. /// </exception> public static Uri CreateAbsoluteUri(string absolutePath) { var uriString = IsAscii(absolutePath) ? absolutePath : GetAbsoluteUriString(absolutePath); try { #pragma warning disable RS0030 // Do not use banned APIs return new(uriString, UriKind.Absolute); #pragma warning restore } catch (UriFormatException e) { // The standard URI format exception does not include the failing path, however // in pretty much all cases we need to know the URI string (and original string) in order to fix the issue. throw new UriFormatException($"Failed create URI from '{uriString}'; original string: '{absolutePath}'", e); } } /// <summary> /// Converts an absolute local file path or an absolute URL string to <see cref="DocumentUri"/>. /// For use with callers (generally LSP) that require <see cref="DocumentUri"/> /// </summary> /// <remarks> /// Unlike <see cref="CreateAbsoluteUri"/>, this method gracefully handles paths that /// <see cref="Uri"/> cannot parse (e.g., UNC paths with <c>$</c> in the server name). /// In such cases, it falls back to manually constructing the URI string, which /// <see cref="DocumentUri"/> stores without requiring <see cref="Uri"/> parsing. /// </remarks> public static DocumentUri CreateAbsoluteDocumentUri(string absolutePath) { try { return new(CreateAbsoluteUri(absolutePath)); } catch (UriFormatException) { // System.Uri can't handle certain valid paths (e.g. UNC paths with $ in server name). // Fall back to constructing the URI string manually. return new(GetAbsoluteUriString(absolutePath)); } } internal static DocumentUri CreateRelativePatternBaseUri(string path) { // According to VSCode LSP RelativePattern spec, // found at https://github.com/microsoft/vscode/blob/9e1974682eb84eebb073d4ae775bad1738c281f6/src/vscode-dts/vscode.d.ts#L2226 // the baseUri should not end in a trailing separator, nor should it // have any relative segmeents (., ..) if (path[^1] == System.IO.Path.DirectorySeparatorChar) { path = path[..^1]; } Debug.Assert(!path.Split(System.IO.Path.DirectorySeparatorChar).Any(p => p == "." || p == "..")); return CreateAbsoluteDocumentUri(path); } // Implements workaround for https://github.com/dotnet/runtime/issues/89538: internal static string GetAbsoluteUriString(string absolutePath) { if (!PathUtilities.IsAbsolute(absolutePath)) { return absolutePath; } var parts = absolutePath.Split(s_dirSeparators); if (PathUtilities.IsUnixLikePlatform) { // Unix path: first part is empty, all parts should be escaped return "file://" + string.Join("/", parts.Select(EscapeUriPart)); } if (parts is ["", "", var serverName, ..]) { // UNC path: first non-empty part is server name and shouldn't be escaped return "file://" + serverName + "/" + string.Join("/", parts.Skip(3).Select(EscapeUriPart)); } // Drive-rooted path: first part is "C:" and shouldn't be escaped return "file:///" + parts[0] + "/" + string.Join("/", parts.Skip(1).Select(EscapeUriPart)); #pragma warning disable SYSLIB0013 // Type or member is obsolete static string EscapeUriPart(string stringToEscape) => Uri.EscapeUriString(stringToEscape).Replace("#", "%23"); #pragma warning restore } private static bool IsAscii(char c) => (uint)c <= '\x007f'; private static bool IsAscii(string filePath) { for (var i = 0; i < filePath.Length; i++) { if (!IsAscii(filePath[i])) { return false; } } return true; } public static LSP.TextDocumentPositionParams PositionToTextDocumentPositionParams(int position, SourceText text, Document document) { return new LSP.TextDocumentPositionParams() { TextDocument = DocumentToTextDocumentIdentifier(document), Position = LinePositionToPosition(text.Lines.GetLinePosition(position)) }; } public static LSP.TextDocumentIdentifier DocumentToTextDocumentIdentifier(TextDocument document) => new() { DocumentUri = document.GetURI() }; public static LSP.VersionedTextDocumentIdentifier DocumentToVersionedTextDocumentIdentifier(Document document) => new() { DocumentUri = document.GetURI() }; public static LinePosition PositionToLinePosition(LSP.Position position) => new(position.Line, position.Character); public static LinePositionSpan RangeToLinePositionSpan(LSP.Range range) => new(PositionToLinePosition(range.Start), PositionToLinePosition(range.End)); public static TextSpan RangeToTextSpan(LSP.Range range, SourceText text) { var linePositionSpan = RangeToLinePositionSpan(range); linePositionSpan = new LinePositionSpan( ClampPositionToLineEnd(linePositionSpan.Start, text), ClampPositionToLineEnd(linePositionSpan.End, text)); // Handle the specific case where the end position is exactly one line beyond the document bounds // and the end character is 0 (start of the non-existent next line). // This can happen when deleting the last line, where LSP clients are allowed (by the spec) to // send an end position referencing the start of the next line (which doesn't exist). if (text.Lines.Count > 0 && linePositionSpan.End.Line == text.Lines.Count && linePositionSpan.End.Character == 0) { // Clamp the end position to the end of the last line var lastLine = text.Lines[text.Lines.Count - 1]; var clampedEnd = new LinePosition(text.Lines.Count - 1, lastLine.End - lastLine.Start); linePositionSpan = new LinePositionSpan(linePositionSpan.Start, clampedEnd); } try { try { return text.Lines.GetTextSpan(linePositionSpan); } catch (ArgumentException ex) { // Create a custom error for this so we can examine the data we're getting. throw new ArgumentException($"Range={RangeToString(range)}. text.Length={text.Length}. text.Lines.Count={text.Lines.Count}", ex); } } // Temporary exception reporting to investigate https://github.com/dotnet/roslyn/issues/66258. catch (Exception e) when (FatalError.ReportAndPropagate(e)) { throw; } static string RangeToString(LSP.Range range) => $"{{ Start={PositionToString(range.Start)}, End={PositionToString(range.End)} }}"; static string PositionToString(LSP.Position position) => $"{{ Line={position.Line}, Character={position.Character} }}"; static LinePosition ClampPositionToLineEnd(LinePosition position, SourceText text) { if (position.Line < 0 || position.Line >= text.Lines.Count) return position; var line = text.Lines[position.Line]; var lineLength = line.End - line.Start; return position.Character > lineLength ? new LinePosition(position.Line, lineLength) : position; } } public static LSP.TextEdit TextChangeToTextEdit(TextChange textChange, SourceText oldText) { Contract.ThrowIfNull(textChange.NewText); return new LSP.TextEdit { NewText = textChange.NewText, Range = TextSpanToRange(textChange.Span, oldText) }; } public static TextChange TextEditToTextChange(LSP.TextEdit edit, SourceText oldText) => new(RangeToTextSpan(edit.Range, oldText), edit.NewText); public static TextChange ContentChangeEventToTextChange(LSP.TextDocumentContentChangePartial changeEvent, SourceText text) => new(RangeToTextSpan(changeEvent.Range, text), changeEvent.Text); public static LSP.Position LinePositionToPosition(LinePosition linePosition) => new() { Line = linePosition.Line, Character = linePosition.Character }; public static LSP.Range LinePositionToRange(LinePositionSpan linePositionSpan) => new() { Start = LinePositionToPosition(linePositionSpan.Start), End = LinePositionToPosition(linePositionSpan.End) }; public static LSP.Range TextSpanToRange(TextSpan textSpan, SourceText text) { var linePosSpan = text.Lines.GetLinePositionSpan(textSpan); return LinePositionToRange(linePosSpan); } public static Task<LSP.Location?> DocumentSpanToLocationAsync(DocumentSpan documentSpan, CancellationToken cancellationToken) => TextSpanToLocationAsync(documentSpan.Document, documentSpan.SourceSpan, isStale: false, cancellationToken); public static async Task<LSP.VSInternalLocation?> DocumentSpanToLocationWithTextAsync( DocumentSpan documentSpan, ClassifiedTextElement text, CancellationToken cancellationToken) { var location = await TextSpanToLocationAsync( documentSpan.Document, documentSpan.SourceSpan, isStale: false, cancellationToken).ConfigureAwait(false); return location == null ? null : new LSP.VSInternalLocation { DocumentUri = location.DocumentUri, Range = location.Range, Text = text }; } /// <summary> /// Compute all the <see cref="LSP.TextDocumentEdit"/> for the input list of changed documents. /// Additionally maps the locations of the changed documents if necessary. /// </summary> public static async Task<LSP.TextDocumentEdit[]> ChangedDocumentsToTextDocumentEditsAsync(Solution newSolution, Solution oldSolution, CancellationToken cancellationToken) { var solutionChanges = newSolution.GetChanges(oldSolution); var changedDocuments = solutionChanges .GetProjectChanges() .SelectMany(p => p.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true)) .GroupBy(docId => newSolution.GetRequiredDocument(docId).FilePath, StringComparer.OrdinalIgnoreCase).Select(group => group.First()); var textDiffService = newSolution.Services.GetRequiredService<IDocumentTextDifferencingService>(); using var _ = ArrayBuilder<(DocumentUri Uri, LSP.TextEdit TextEdit)>.GetInstance(out var uriToTextEdits); foreach (var docId in changedDocuments) { var newDocument = newSolution.GetRequiredDocument(docId); var oldDocument = oldSolution.GetRequiredDocument(docId); var oldText = await oldDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false); ImmutableArray<TextChange> textChanges; // Normal documents have a unique service for calculating minimal text edits. If we used the standard 'GetTextChanges' // method instead, we would get a change that spans the entire document, which we ideally want to avoid. if (newDocument is Document newDoc && oldDocument is Document oldDoc) { Contract.ThrowIfNull(textDiffService); textChanges = await textDiffService.GetTextChangesAsync(oldDoc, newDoc, cancellationToken).ConfigureAwait(false); } else { var newText = await newDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false); textChanges = [.. newText.GetTextChanges(oldText)]; } // Map all the text changes' spans for this document. var mappedResults = await SpanMappingHelper.TryGetMappedSpanResultAsync(oldDocument, [.. textChanges.Select(tc => tc.Span)], cancellationToken).ConfigureAwait(false); if (mappedResults == null) { // There's no span mapping available, just create text edits from the original text changes. foreach (var textChange in textChanges) { uriToTextEdits.Add((oldDocument.GetURI(), TextChangeToTextEdit(textChange, oldText))); } } else { // We have mapping results, so create text edits from the mapped text change spans. for (var i = 0; i < textChanges.Length; i++) { var mappedSpan = mappedResults.Value[i]; var textChange = textChanges[i]; if (!mappedSpan.IsDefault) { uriToTextEdits.Add((CreateAbsoluteDocumentUri(mappedSpan.FilePath), new LSP.TextEdit { Range = MappedSpanResultToRange(mappedSpan), NewText = textChange.NewText ?? string.Empty })); } } } } // Now process source generated documents that might have changed, via the source generated document mapping service // We have to ensure the old solution has run the generators so the mapper has something to compare to, but we only do // it for FrozenSourceGeneratedDocumentStates, so only documents that the rename engine thought were worthy of touching, // which in practical terms at time of writing this comment, means Razor. foreach (var (docId, state) in solutionChanges.NewSolution.CompilationState.FrozenSourceGeneratedDocumentStates.States) { var document = await solutionChanges.OldSolution.GetRequiredDocumentAsync(docId, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); Contract.ThrowIfFalse(document.IsRazorSourceGeneratedDocument()); } var sourceGeneratedDocumentMappingService = newSolution.Services.GetService<ISourceGeneratedDocumentSpanMappingService>(); foreach (var docId in solutionChanges.GetExplicitlyChangedSourceGeneratedDocuments()) { var oldDocument = solutionChanges.OldSolution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId(docId); var newDocument = solutionChanges.NewSolution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId(docId); if (sourceGeneratedDocumentMappingService?.CanMapSpans(oldDocument) == true) { var mappedTextChanges = await sourceGeneratedDocumentMappingService.GetMappedTextChangesAsync(oldDocument, newDocument, cancellationToken).ConfigureAwait(false); foreach (var (filePath, textChange) in mappedTextChanges) { var mappedDocId = oldSolution.GetDocumentIdsWithFilePath(filePath).FirstOrDefault(d => d.ProjectId == oldDocument.Id.ProjectId); // Can't map to an edit in an unknown document if (mappedDocId is null) continue; var mappedDoc = oldSolution.GetRequiredTextDocument(mappedDocId); var mappedText = await mappedDoc.GetTextAsync(cancellationToken).ConfigureAwait(false); uriToTextEdits.Add((CreateAbsoluteDocumentUri(filePath), new LSP.TextEdit { Range = TextSpanToRange(textChange.Span, mappedText), NewText = textChange.NewText ?? string.Empty })); } } else { // There's no span mapping available, just create text edits from the original text changes. var oldText = await oldDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false); var textChanges = await textDiffService.GetTextChangesAsync(oldDocument, newDocument, cancellationToken).ConfigureAwait(false); foreach (var textChange in textChanges) { uriToTextEdits.Add((oldDocument.GetURI(), TextChangeToTextEdit(textChange, oldText))); } } } var documentEdits = uriToTextEdits.GroupBy(uriAndEdit => uriAndEdit.Uri, uriAndEdit => new LSP.SumType<LSP.TextEdit, LSP.AnnotatedTextEdit>(uriAndEdit.TextEdit), (uri, edits) => new LSP.TextDocumentEdit { TextDocument = new LSP.OptionalVersionedTextDocumentIdentifier { DocumentUri = uri }, Edits = [.. edits], }).ToArray(); return documentEdits; } public static Task<LSP.Location?> TextSpanToLocationAsync( TextDocument document, TextSpan textSpan, bool isStale, CancellationToken cancellationToken) { return TextSpanToLocationAsync(document, textSpan, isStale, context: null, cancellationToken); } public static async Task<LSP.Location?> TextSpanToLocationAsync( TextDocument document, TextSpan textSpan, bool isStale, RequestContext? context, CancellationToken cancellationToken) { Debug.Assert(document.FilePath != null); if (document is Document d && SpanMappingHelper.CanMapSpans(d)) { var result = await SpanMappingHelper.TryGetMappedSpanResultAsync(d, [textSpan], cancellationToken).ConfigureAwait(false); if (result is not [{ IsDefault: false } mappedSpan]) { // Couldn't map the span, but mapping is supported, so the mapper must not want to show include this result return null; } DocumentUri? uri = null; try { if (PathUtilities.IsAbsolute(mappedSpan.FilePath)) uri = CreateAbsoluteDocumentUri(mappedSpan.FilePath); } catch (UriFormatException) { } if (uri == null) { context?.TraceWarning($"Could not convert '{mappedSpan.FilePath}' to uri"); return null; } return new LSP.Location { DocumentUri = uri, Range = MappedSpanResultToRange(mappedSpan) }; } return await ConvertTextSpanToLocationAsync(document, textSpan, isStale, cancellationToken).ConfigureAwait(false); static async Task<LSP.Location> ConvertTextSpanToLocationAsync( TextDocument document, TextSpan span, bool isStale, CancellationToken cancellationToken) { var uri = document.GetURI(); var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false); if (isStale) { // in the case of a stale item, the span may be out of bounds of the document. Cap // us to the end of the document as that's where we're going to navigate the user // to. span = TextSpan.FromBounds( Math.Min(text.Length, span.Start), Math.Min(text.Length, span.End)); } return ConvertTextSpanWithTextToLocation(span, text, uri); } static LSP.Location ConvertTextSpanWithTextToLocation(TextSpan span, SourceText text, DocumentUri documentUri) { var location = new LSP.Location { DocumentUri = documentUri, Range = TextSpanToRange(span, text), }; return location; } } public static LSP.CodeDescription? HelpLinkToCodeDescription(Uri? uri) => (uri != null) ? new LSP.CodeDescription { Href = new(uri) } : null; public static LSP.SymbolKind NavigateToKindToSymbolKind(string kind) { if (Enum.TryParse<LSP.SymbolKind>(kind, out var symbolKind)) { return symbolKind; } // TODO - Define conversion from NavigateToItemKind to LSP Symbol kind switch (kind) { case NavigateToItemKind.EnumItem: return LSP.SymbolKind.EnumMember; case NavigateToItemKind.Structure: return LSP.SymbolKind.Struct; case NavigateToItemKind.Delegate: return LSP.SymbolKind.Function; default: return LSP.SymbolKind.Object; } } public static LSP.DocumentHighlightKind HighlightSpanKindToDocumentHighlightKind(HighlightSpanKind kind) { switch (kind) { case HighlightSpanKind.Reference: return LSP.DocumentHighlightKind.Read; case HighlightSpanKind.WrittenReference: return LSP.DocumentHighlightKind.Write; default: return LSP.DocumentHighlightKind.Text; } } public static LSP.VSInternalSpellCheckableRangeKind SpellCheckSpanKindToSpellCheckableRangeKind(SpellCheckKind kind) => kind switch { SpellCheckKind.Identifier => LSP.VSInternalSpellCheckableRangeKind.Identifier, SpellCheckKind.Comment => LSP.VSInternalSpellCheckableRangeKind.Comment, SpellCheckKind.String => LSP.VSInternalSpellCheckableRangeKind.String, _ => throw ExceptionUtilities.UnexpectedValue(kind), }; public static Glyph SymbolKindToGlyph(LSP.SymbolKind kind) { switch (kind) { case LSP.SymbolKind.File: return Glyph.CSharpFile; case LSP.SymbolKind.Module: return Glyph.ModulePublic; case LSP.SymbolKind.Namespace: return Glyph.Namespace; case LSP.SymbolKind.Package: return Glyph.Assembly; case LSP.SymbolKind.Class: return Glyph.ClassPublic; case LSP.SymbolKind.Method: return Glyph.MethodPublic; case LSP.SymbolKind.Property: return Glyph.PropertyPublic; case LSP.SymbolKind.Field: return Glyph.FieldPublic; case LSP.SymbolKind.Constructor: return Glyph.MethodPublic; case LSP.SymbolKind.Enum: return Glyph.EnumPublic; case LSP.SymbolKind.Interface: return Glyph.InterfacePublic; case LSP.SymbolKind.Function: return Glyph.DelegatePublic; case LSP.SymbolKind.Variable: return Glyph.Local; case LSP.SymbolKind.Constant: case LSP.SymbolKind.Number: return Glyph.ConstantPublic; case LSP.SymbolKind.String: case LSP.SymbolKind.Boolean: case LSP.SymbolKind.Array: case LSP.SymbolKind.Object: case LSP.SymbolKind.Key: case LSP.SymbolKind.Null: return Glyph.Local; case LSP.SymbolKind.EnumMember: return Glyph.EnumMemberPublic; case LSP.SymbolKind.Struct: return Glyph.StructurePublic; case LSP.SymbolKind.Event: return Glyph.EventPublic; case LSP.SymbolKind.Operator: return Glyph.OperatorPublic; case LSP.SymbolKind.TypeParameter: return Glyph.TypeParameter; default: return Glyph.None; } } public static LSP.SymbolKind GlyphToSymbolKind(Glyph glyph) { // Glyph kinds have accessibility modifiers in their name, e.g. ClassPrivate. // Remove the accessibility modifier and try to convert to LSP symbol kind. var glyphString = glyph.ToString().Replace(nameof(Accessibility.Public), string.Empty) .Replace(nameof(Accessibility.Protected), string.Empty) .Replace(nameof(Accessibility.Private), string.Empty) .Replace(nameof(Accessibility.Internal), string.Empty); if (Enum.TryParse<LSP.SymbolKind>(glyphString, out var symbolKind)) { return symbolKind; } switch (glyph) { case Glyph.Assembly: case Glyph.BasicProject: case Glyph.CSharpProject: case Glyph.NuGet: return LSP.SymbolKind.Package; case Glyph.BasicFile: case Glyph.CSharpFile: return LSP.SymbolKind.File; case Glyph.DelegatePublic: case Glyph.DelegateProtected: case Glyph.DelegatePrivate: case Glyph.DelegateInternal: case Glyph.ExtensionMethodPublic: case Glyph.ExtensionMethodProtected: case Glyph.ExtensionMethodPrivate: case Glyph.ExtensionMethodInternal: return LSP.SymbolKind.Method; case Glyph.Local: case Glyph.Parameter: case Glyph.RangeVariable: case Glyph.Reference: return LSP.SymbolKind.Variable; case Glyph.StructurePublic: case Glyph.StructureProtected: case Glyph.StructurePrivate: case Glyph.StructureInternal: return LSP.SymbolKind.Struct; default: return LSP.SymbolKind.Object; } } public static Glyph CompletionItemKindToGlyph(LSP.CompletionItemKind kind) { switch (kind) { case LSP.CompletionItemKind.Text: return Glyph.None; case LSP.CompletionItemKind.Method: case LSP.CompletionItemKind.Constructor: case LSP.CompletionItemKind.Function: // We don't use Function, but map it just in case. It has the same icon as Method in VS and VS Code return Glyph.MethodPublic; case LSP.CompletionItemKind.Field: return Glyph.FieldPublic; case LSP.CompletionItemKind.Variable: case LSP.CompletionItemKind.Unit: case LSP.CompletionItemKind.Value: return Glyph.Local; case LSP.CompletionItemKind.Class: return Glyph.ClassPublic; case LSP.CompletionItemKind.Interface: return Glyph.InterfacePublic; case LSP.CompletionItemKind.Module: return Glyph.ModulePublic; case LSP.CompletionItemKind.Property: return Glyph.PropertyPublic; case LSP.CompletionItemKind.Enum: return Glyph.EnumPublic; case LSP.CompletionItemKind.Keyword: return Glyph.Keyword; case LSP.CompletionItemKind.Snippet: return Glyph.Snippet; case LSP.CompletionItemKind.Color: return Glyph.None; case LSP.CompletionItemKind.File: return Glyph.CSharpFile; case LSP.CompletionItemKind.Reference: return Glyph.Reference; case LSP.CompletionItemKind.Folder: return Glyph.OpenFolder; case LSP.CompletionItemKind.EnumMember: return Glyph.EnumMemberPublic; case LSP.CompletionItemKind.Constant: return Glyph.ConstantPublic; case LSP.CompletionItemKind.Struct: return Glyph.StructurePublic; case LSP.CompletionItemKind.Event: return Glyph.EventPublic; case LSP.CompletionItemKind.Operator: return Glyph.OperatorPublic; case LSP.CompletionItemKind.TypeParameter: return Glyph.TypeParameter; default: return Glyph.None; } } // The mappings here are roughly based off of SymbolUsageInfoExtensions.ToSymbolReferenceKinds. public static LSP.VSInternalReferenceKind[] SymbolUsageInfoToReferenceKinds(SymbolUsageInfo symbolUsageInfo) { using var _ = ArrayBuilder<LSP.VSInternalReferenceKind>.GetInstance(out var referenceKinds); if (symbolUsageInfo.ValueUsageInfoOpt.HasValue) { var usageInfo = symbolUsageInfo.ValueUsageInfoOpt.Value; if (usageInfo.IsReadFrom()) { referenceKinds.Add(LSP.VSInternalReferenceKind.Read); } if (usageInfo.IsWrittenTo()) { referenceKinds.Add(LSP.VSInternalReferenceKind.Write); } if (usageInfo.IsReference()) { referenceKinds.Add(LSP.VSInternalReferenceKind.Reference); } if (usageInfo.IsNameOnly()) { referenceKinds.Add(LSP.VSInternalReferenceKind.Name); } } if (symbolUsageInfo.TypeOrNamespaceUsageInfoOpt.HasValue) { var usageInfo = symbolUsageInfo.TypeOrNamespaceUsageInfoOpt.Value; if ((usageInfo & TypeOrNamespaceUsageInfo.Qualified) != 0) { referenceKinds.Add(LSP.VSInternalReferenceKind.Qualified); } if ((usageInfo & TypeOrNamespaceUsageInfo.TypeArgument) != 0) { referenceKinds.Add(LSP.VSInternalReferenceKind.TypeArgument); } if ((usageInfo & TypeOrNamespaceUsageInfo.TypeConstraint) != 0) { referenceKinds.Add(LSP.VSInternalReferenceKind.TypeConstraint); } if ((usageInfo & TypeOrNamespaceUsageInfo.Base) != 0) { referenceKinds.Add(LSP.VSInternalReferenceKind.BaseType); } // Preserving the same mapping logic that SymbolUsageInfoExtensions.ToSymbolReferenceKinds uses if ((usageInfo & TypeOrNamespaceUsageInfo.ObjectCreation) != 0) { referenceKinds.Add(LSP.VSInternalReferenceKind.Constructor); } if ((usageInfo & TypeOrNamespaceUsageInfo.Import) != 0) { referenceKinds.Add(LSP.VSInternalReferenceKind.Import); } // Preserving the same mapping logic that SymbolUsageInfoExtensions.ToSymbolReferenceKinds uses if ((usageInfo & TypeOrNamespaceUsageInfo.NamespaceDeclaration) != 0) { referenceKinds.Add(LSP.VSInternalReferenceKind.Declaration); } } return referenceKinds.ToArray(); } public static string ProjectIdToProjectContextId(ProjectId id) { return id.Id + "|" + id.DebugName; } public static ProjectId ProjectContextToProjectId(LSP.VSProjectContext projectContext) { var delimiter = projectContext.Id.IndexOf('|'); return ProjectId.CreateFromSerialized( Guid.Parse(projectContext.Id[..delimiter]), debugName: projectContext.Id[(delimiter + 1)..]); } public static LSP.VSProjectContext ProjectToProjectContext(Project project) { var projectContext = new LSP.VSProjectContext { Id = ProjectIdToProjectContextId(project.Id), Label = project.Name, // IsMiscellaneous controls whether a toast appears which warns that editor features are not available. // In case HasAllInformation is true, though, we do actually have all information needed to light up any features user is trying to use related to the project. IsMiscellaneous = project.Solution.WorkspaceKind == WorkspaceKind.MiscellaneousFiles && !project.State.HasAllInformation, }; if (project.Language == LanguageNames.CSharp) { projectContext.Kind = LSP.VSProjectKind.CSharp; } else if (project.Language == LanguageNames.VisualBasic) { projectContext.Kind = LSP.VSProjectKind.VisualBasic; } return projectContext; } public static async Task<SyntaxFormattingOptions> GetFormattingOptionsAsync( LSP.FormattingOptions? options, Document document, CancellationToken cancellationToken) { var formattingOptions = await document.GetSyntaxFormattingOptionsAsync(cancellationToken).ConfigureAwait(false); if (options != null) { // LSP doesn't currently support indent size as an option. However, except in special // circumstances, indent size is usually equivalent to tab size, so we'll just set it. formattingOptions = formattingOptions with { LineFormatting = new() { UseTabs = !options.InsertSpaces, TabSize = options.TabSize, IndentationSize = options.TabSize, NewLine = formattingOptions.NewLine } }; } return formattingOptions; } public static LSP.MarkupContent GetDocumentationMarkupContent(ImmutableArray<TaggedText> tags, TextDocument document, bool featureSupportsMarkdown) => GetDocumentationMarkupContent(tags, document.Project.Language, featureSupportsMarkdown); public static LSP.MarkupContent GetDocumentationMarkupContent(ImmutableArray<TaggedText> tags, string language, bool featureSupportsMarkdown) { if (!featureSupportsMarkdown) { return new LSP.MarkupContent { Kind = LSP.MarkupKind.PlainText, Value = tags.GetFullText(), }; } using var markdownBuilder = new MarkdownContentBuilder(); string? codeFence = null; foreach (var taggedText in tags) { switch (taggedText.Tag) { case TextTags.CodeBlockStart: if (markdownBuilder.IsLineEmpty()) { // If the current line is empty, we can append a code block. codeFence = BlockCodeFence; var codeBlockLanguageName = GetCodeBlockLanguageName(language); markdownBuilder.AppendLine($"{codeFence}{codeBlockLanguageName}"); markdownBuilder.AppendLine(taggedText.Text); } else { // There is text on the line already - we should append an in-line code block. codeFence = InlineCodeFence; markdownBuilder.Append(codeFence + taggedText.Text); } break; case TextTags.CodeBlockEnd: if (codeFence == BlockCodeFence) { markdownBuilder.AppendLine(codeFence); markdownBuilder.AppendLine(taggedText.Text); } else if (codeFence == InlineCodeFence) { markdownBuilder.Append(codeFence + taggedText.Text); } else { throw ExceptionUtilities.UnexpectedValue(codeFence); } codeFence = null; break; case TextTags.Text when taggedText.Style == (TaggedTextStyle.Code | TaggedTextStyle.PreserveWhitespace): // This represents a block of code (`<code></code>`) in doc comments. // Since code elements optionally support a `lang` attribute and we do not have access to the // language which was specified at this point, we tell the client to render it as plain text. if (!markdownBuilder.IsLineEmpty()) AppendLineBreak(markdownBuilder); // The current line is empty, we can append a code block. markdownBuilder.AppendLine($"{BlockCodeFence}text"); markdownBuilder.AppendLine(taggedText.Text); markdownBuilder.AppendLine(BlockCodeFence); break; case TextTags.LineBreak: AppendLineBreak(markdownBuilder); break; default: var styledText = GetStyledText(taggedText, codeFence != null); markdownBuilder.Append(styledText); break; } } var content = markdownBuilder.Build(Environment.NewLine); return new LSP.MarkupContent { Kind = LSP.MarkupKind.Markdown, Value = content, }; static void AppendLineBreak(MarkdownContentBuilder markdownBuilder) { // A line ending with double space and a new line indicates to markdown // to render a single-spaced line break. markdownBuilder.Append(" "); markdownBuilder.AppendLine(); } static string GetCodeBlockLanguageName(string language) { return language switch { (LanguageNames.CSharp) => CSharpMarkdownLanguageName, (LanguageNames.VisualBasic) => VisualBasicMarkdownLanguageName, _ => throw new InvalidOperationException($"{language} is not supported"), }; } static string GetStyledText(TaggedText taggedText, bool isInCodeBlock) { var isCode = isInCodeBlock || taggedText.Style is TaggedTextStyle.Code; var text = isCode ? taggedText.Text : s_markdownEscapeRegex.Replace(taggedText.Text, @"\$1"); // For non-cref links, the URI is present in both the hint and target. if (!string.IsNullOrEmpty(taggedText.NavigationHint) && taggedText.NavigationHint == taggedText.NavigationTarget) return $"[{text}]({taggedText.NavigationHint})"; // Markdown ignores spaces at the start of lines outside of code blocks, // so we replace regular spaces with non-breaking spaces to ensure structural space is retained. // We want to use regular spaces everywhere else to allow the client to wrap long text. if (!isCode && taggedText.Tag is TextTags.Space or TextTags.ContainerStart) text = text.Replace(" ", " "); return taggedText.Style switch { TaggedTextStyle.None => text, TaggedTextStyle.Strong => $"**{text}**", TaggedTextStyle.Emphasis => $"_{text}_", TaggedTextStyle.Underline => $"<u>{text}</u>", // Use double backticks to escape code which contains a backtick. TaggedTextStyle.Code => text.Contains('`') ? $"``{text}``" : $"`{text}`", _ => text, }; } } private static LSP.Range MappedSpanResultToRange(MappedSpanResult mappedSpanResult) { return new LSP.Range { Start = LinePositionToPosition(mappedSpanResult.LinePositionSpan.Start), End = LinePositionToPosition(mappedSpanResult.LinePositionSpan.End) }; } }