File: Handler\OnAutoInsert\OnAutoInsertHandler.cs
Web Access
Project: src\src\LanguageServer\Protocol\Microsoft.CodeAnalysis.LanguageServer.Protocol.csproj (Microsoft.CodeAnalysis.LanguageServer.Protocol)
// 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.Collections.Generic;
using System.Collections.Immutable;
using System.Composition;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.BraceCompletion;
using Microsoft.CodeAnalysis.DocumentationComments;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Indentation;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
using static Microsoft.CodeAnalysis.Completion.Utilities;
using LSP = Roslyn.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler;
 
[ExportCSharpVisualBasicStatelessLspService(typeof(OnAutoInsertHandler)), Shared]
[Method(LSP.VSInternalMethods.OnAutoInsertName)]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed class OnAutoInsertHandler(
    [ImportMany] IEnumerable<Lazy<IBraceCompletionService, LanguageMetadata>> braceCompletionServices,
    IGlobalOptionService globalOptions) : ILspServiceDocumentRequestHandler<LSP.VSInternalDocumentOnAutoInsertParams, LSP.VSInternalDocumentOnAutoInsertResponseItem?>
{
    private readonly ImmutableArray<Lazy<IBraceCompletionService, LanguageMetadata>> _braceCompletionServices = [.. braceCompletionServices];
    private readonly IGlobalOptionService _globalOptions = globalOptions;
 
    public bool MutatesSolutionState => false;
    public bool RequiresLSPSolution => true;
 
    public LSP.TextDocumentIdentifier GetTextDocumentIdentifier(LSP.VSInternalDocumentOnAutoInsertParams request) => request.TextDocument;
 
    public Task<LSP.VSInternalDocumentOnAutoInsertResponseItem?> HandleRequestAsync(
        LSP.VSInternalDocumentOnAutoInsertParams request,
        RequestContext context,
        CancellationToken cancellationToken)
    {
        var document = context.Document;
        if (document == null)
            return SpecializedTasks.Null<LSP.VSInternalDocumentOnAutoInsertResponseItem>();
 
        var onAutoInsertEnabled = _globalOptions.GetOption(LspOptionsStorage.LspEnableAutoInsert, document.Project.Language);
        if (!onAutoInsertEnabled)
            return SpecializedTasks.Null<LSP.VSInternalDocumentOnAutoInsertResponseItem>();
 
        var servicesForDocument = _braceCompletionServices.Where(s => s.Metadata.Language == document.Project.Language).SelectAsArray(s => s.Value);
        var isRazorRequest = context.ServerKind == WellKnownLspServerKinds.RazorLspServer;
        var position = ProtocolConversions.PositionToLinePosition(request.Position);
        return GetOnAutoInsertResponseAsync(_globalOptions, servicesForDocument, document, position, request.Character, request.Options, isRazorRequest, cancellationToken);
    }
 
    internal static async Task<LSP.VSInternalDocumentOnAutoInsertResponseItem?> GetOnAutoInsertResponseAsync(
        IGlobalOptionService globalOptions,
        ImmutableArray<IBraceCompletionService> servicesForDocument,
        Document document,
        LinePosition linePosition,
        string character,
        LSP.FormattingOptions lspFormattingOptions,
        bool isRazorRequest,
        CancellationToken cancellationToken)
    {
        var service = document.GetRequiredLanguageService<IDocumentationCommentSnippetService>();
 
        // We should use the options passed in by LSP instead of the document's options.
        var formattingOptions = await ProtocolConversions.GetFormattingOptionsAsync(lspFormattingOptions, document, cancellationToken).ConfigureAwait(false);
 
        // The editor calls this handler for C# and VB comment characters, but we only need to process the one for the language that matches the document
        if (character == "\n" || character == service.DocumentationCommentCharacter)
        {
            var docCommentOptions = globalOptions.GetDocumentationCommentOptions(formattingOptions.LineFormatting, document.Project.Language);
 
            var documentationCommentResponse = await GetDocumentationCommentResponseAsync(
                document, linePosition, character, service, docCommentOptions, cancellationToken).ConfigureAwait(false);
 
            if (documentationCommentResponse != null)
            {
                return documentationCommentResponse;
            }
        }
 
        // Only support this for razor as LSP doesn't support overtype yet.
        // https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1165179/
        // Once LSP supports overtype we can move all of brace completion to LSP.
        if (character == "\n" && isRazorRequest)
        {
            var indentationOptions = new IndentationOptions(formattingOptions)
            {
                AutoFormattingOptions = globalOptions.GetAutoFormattingOptions(document.Project.Language)
            };
 
            var braceCompletionAfterReturnResponse = await GetBraceCompletionAfterReturnResponseAsync(
                document, servicesForDocument, linePosition, indentationOptions, cancellationToken).ConfigureAwait(false);
            if (braceCompletionAfterReturnResponse != null)
            {
                return braceCompletionAfterReturnResponse;
            }
        }
 
        return null;
    }
 
    private static async Task<LSP.VSInternalDocumentOnAutoInsertResponseItem?> GetDocumentationCommentResponseAsync(
        Document document,
        LinePosition linePosition,
        string character,
        IDocumentationCommentSnippetService service,
        DocumentationCommentOptions options,
        CancellationToken cancellationToken)
    {
        var parsedDocument = await ParsedDocument.CreateAsync(document, cancellationToken).ConfigureAwait(false);
        var sourceText = parsedDocument.Text;
 
        var position = sourceText.Lines.GetPosition(linePosition);
 
        var result = character == "\n"
            ? service.GetDocumentationCommentSnippetOnEnterTyped(parsedDocument, position, options, cancellationToken)
            : service.GetDocumentationCommentSnippetOnCharacterTyped(parsedDocument, position, options, cancellationToken, addIndentation: false);
 
        if (result == null)
            return null;
 
        return new LSP.VSInternalDocumentOnAutoInsertResponseItem
        {
            TextEditFormat = LSP.InsertTextFormat.Snippet,
            TextEdit = new LSP.TextEdit
            {
                NewText = result.SnippetText.Insert(result.CaretOffset, "$0"),
                Range = ProtocolConversions.TextSpanToRange(result.SpanToReplace, sourceText)
            }
        };
    }
 
    private static async Task<LSP.VSInternalDocumentOnAutoInsertResponseItem?> GetBraceCompletionAfterReturnResponseAsync(
        Document document,
        ImmutableArray<IBraceCompletionService> servicesForDocument,
        LinePosition linePosition,
        IndentationOptions options,
        CancellationToken cancellationToken)
    {
        var sourceText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
        var position = sourceText.Lines.GetPosition(linePosition);
 
        var serviceAndContext = await GetBraceCompletionContextAsync(servicesForDocument, position, document, cancellationToken).ConfigureAwait(false);
        if (serviceAndContext == null)
        {
            return null;
        }
 
        var (service, context) = serviceAndContext.Value;
        var postReturnEdit = service.GetTextChangeAfterReturn(context, options, cancellationToken);
        if (postReturnEdit == null)
        {
            return null;
        }
 
        var textChanges = postReturnEdit.Value.TextChanges;
        var desiredCaretLinePosition = postReturnEdit.Value.CaretLocation;
        var newSourceText = sourceText.WithChanges(textChanges);
 
        var caretLine = newSourceText.Lines[desiredCaretLinePosition.Line];
        if (desiredCaretLinePosition.Character > caretLine.Span.Length)
        {
            if (caretLine.Span.IsEmpty)
            {
                // We have an empty line with the caret column at an indented position, let's add whitespace indentation to the text.
                var indentedText = GetIndentedText(newSourceText, caretLine, desiredCaretLinePosition, options);
 
                // Get the overall text changes between the original text and the formatted + indented text.
                textChanges = [.. indentedText.GetTextChanges(sourceText)];
                newSourceText = indentedText;
 
                // If tabs were inserted the desired caret column can remain beyond the line text.
                // So just set the caret position to the end of the newly indented line.
                var caretLineInIndentedText = indentedText.Lines[desiredCaretLinePosition.Line];
                desiredCaretLinePosition = indentedText.Lines.GetLinePosition(caretLineInIndentedText.End);
            }
            else
            {
                // We're not on an empty line, clamp the line position to the actual line end.
                desiredCaretLinePosition = new LinePosition(desiredCaretLinePosition.Line, Math.Min(desiredCaretLinePosition.Character, caretLine.End));
            }
        }
 
        var textChange = await GetCollapsedChangeAsync(textChanges, document, cancellationToken).ConfigureAwait(false);
        var newText = GetTextChangeTextWithCaretAtLocation(newSourceText, textChange, desiredCaretLinePosition);
        var autoInsertChange = new LSP.VSInternalDocumentOnAutoInsertResponseItem
        {
            TextEditFormat = LSP.InsertTextFormat.Snippet,
            TextEdit = new LSP.TextEdit
            {
                NewText = newText,
                Range = ProtocolConversions.TextSpanToRange(textChange.Span, sourceText)
            }
        };
 
        return autoInsertChange;
 
        static SourceText GetIndentedText(
            SourceText textToIndent,
            TextLine lineToIndent,
            LinePosition desiredCaretLinePosition,
            IndentationOptions options)
        {
            // Indent by the amount needed to make the caret line contain the desired indentation column.
            var amountToIndent = desiredCaretLinePosition.Character - lineToIndent.Span.Length;
 
            // Create and apply a text change with whitespace for the indentation amount.
            var indentText = amountToIndent.CreateIndentationString(options.FormattingOptions.UseTabs, options.FormattingOptions.TabSize);
            var indentedText = textToIndent.WithChanges(new TextChange(new TextSpan(lineToIndent.End, 0), indentText));
            return indentedText;
        }
 
        static async Task<TextChange> GetCollapsedChangeAsync(ImmutableArray<TextChange> textChanges, Document oldDocument, CancellationToken cancellationToken)
        {
            var documentText = await oldDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
            documentText = documentText.WithChanges(textChanges);
            return Collapse(documentText, textChanges);
        }
 
        static string GetTextChangeTextWithCaretAtLocation(SourceText sourceText, TextChange textChange, LinePosition desiredCaretLinePosition)
        {
            var desiredCaretLocation = sourceText.Lines.GetPosition(desiredCaretLinePosition);
            Debug.Assert(desiredCaretLocation >= textChange.Span.Start);
            var offsetInTextChange = desiredCaretLocation - textChange.Span.Start;
            var newText = textChange.NewText!.Insert(offsetInTextChange, "$0");
            return newText;
        }
    }
 
    private static async Task<(IBraceCompletionService Service, BraceCompletionContext Context)?> GetBraceCompletionContextAsync(ImmutableArray<IBraceCompletionService> servicesForDocument, int caretLocation, Document document, CancellationToken cancellationToken)
    {
        var parsedDocument = await ParsedDocument.CreateAsync(document, cancellationToken).ConfigureAwait(false);
        var fallbackOptions = document.Project.GetFallbackAnalyzerOptions();
 
        foreach (var service in servicesForDocument)
        {
            var context = service.GetCompletedBraceContext(parsedDocument, fallbackOptions, caretLocation);
            if (context != null)
            {
                return (service, context.Value);
            }
        }
 
        return null;
    }
}