File: Completion\Delegation\DelegatedCompletionHelper.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Protocol.Completion;
 
namespace Microsoft.CodeAnalysis.Razor.Completion.Delegation;
 
/// <summary>
/// Helper methods for C# and HTML completion ("delegated" completion) that are used both in LSP and cohosting
/// completion handler code.
/// </summary>
internal static class DelegatedCompletionHelper
{
    // Ordering should be:
    // 1. Changes items
    // 2. Adds items
    // 3. Filters items
    private static readonly ImmutableArray<IDelegatedCSharpCompletionResponseRewriter> s_delegatedCSharpCompletionResponseRewriters =
        [new SnippetResponseRewriter(), new TextEditResponseRewriter(), new DesignTimeHelperResponseRewriter()];
 
    // Currently we only have one HTML response re-writer. Should we ever need more, we can create a common base and a collection
    private static readonly HtmlCommitCharacterResponseRewriter s_delegatedHtmlCompletionResponseRewriter = new();
 
    /// <summary>
    /// Modifies completion context if needed so that it's acceptable to the delegated language.
    /// </summary>
    /// <param name="context">Original completion context passed to the completion handler</param>
    /// <param name="languageKind">Language of the completion position</param>
    /// <param name="triggerAndCommitCharacters">Per-client set of trigger and commit characters</param>
    /// <returns>Possibly modified completion context</returns>
    /// <remarks>For example, if we invoke C# completion in Razor via @ character, we will not
    /// want C# to see @ as the trigger character and instead will transform completion context
    /// into "invoked" and "explicit" rather than "typing", without a trigger character</remarks>
    public static VSInternalCompletionContext? RewriteContext(
        VSInternalCompletionContext context,
        RazorLanguageKind languageKind,
        CompletionTriggerAndCommitCharacters triggerAndCommitCharacters)
    {
        Debug.Assert(languageKind != RazorLanguageKind.Razor,
            $"{nameof(RewriteContext)} should be called for delegated completion only");
 
        if (context.TriggerKind != CompletionTriggerKind.TriggerCharacter
            || context.TriggerCharacter is not { } triggerCharacter)
        {
            // Non-triggered based completion, the existing context is valid.
            return context;
        }
 
        if (languageKind == RazorLanguageKind.CSharp
            && triggerAndCommitCharacters.IsCSharpTriggerCharacter(triggerCharacter))
        {
            // C# trigger character for C# content
            return context;
        }
 
        if (languageKind == RazorLanguageKind.Html)
        {
            // For HTML we don't want to delegate to HTML language server if completion is due to a trigger characters that is not
            // HTML trigger character. Doing so causes bad side effects in VSCode HTML client as we will end up with non-matching
            // completion entries
            return triggerAndCommitCharacters.IsHtmlTriggerCharacter(triggerCharacter) ? context : null;
        }
 
        // Trigger character not associated with the current language. Transform the context into an invoked context.
        var rewrittenContext = new VSInternalCompletionContext()
        {
            InvokeKind = context.InvokeKind,
            TriggerKind = CompletionTriggerKind.Invoked,
        };
 
        if (languageKind == RazorLanguageKind.CSharp
            && triggerAndCommitCharacters.IsTransitionCharacter(triggerCharacter))
        {
            // The C# language server will not return any completions for the '@' character unless we
            // send the completion request explicitly.
            rewrittenContext.InvokeKind = VSInternalCompletionInvokeKind.Explicit;
        }
 
        return rewrittenContext;
    }
 
    /// <summary>
    /// Modifies C# completion response to be usable by Razor.
    /// </summary>
    /// <returns>
    /// Possibly modified completion response.
    /// </returns>
    public static RazorVSInternalCompletionList RewriteCSharpResponse(
        RazorVSInternalCompletionList? delegatedResponse,
        int absoluteIndex,
        RazorCodeDocument codeDocument,
        Position projectedPosition,
        RazorCompletionOptions completionOptions)
    {
        if (delegatedResponse?.Items is null)
        {
            // If we don't get a response from the delegated server, we have to make sure to return an incomplete completion
            // list. When a user is typing quickly, the delegated request from the first keystroke will fail to synchronize,
            // so if we return a "complete" list then the query won't re-query us for completion once the typing stops/slows
            // so we'd only ever return Razor completion items.
            return new RazorVSInternalCompletionList() { IsIncomplete = true, Items = [] };
        }
 
        var rewrittenResponse = delegatedResponse;
 
        foreach (var rewriter in s_delegatedCSharpCompletionResponseRewriters)
        {
            rewrittenResponse = rewriter.Rewrite(
                rewrittenResponse,
                codeDocument,
                absoluteIndex,
                projectedPosition,
                completionOptions);
        }
 
        return rewrittenResponse;
    }
 
    public static RazorVSInternalCompletionList RewriteHtmlResponse(
        RazorVSInternalCompletionList delegatedResponse,
        RazorCompletionOptions completionOptions)
    {
        var rewrittenResponse = s_delegatedHtmlCompletionResponseRewriter.Rewrite(
            delegatedResponse,
            completionOptions);
 
        return rewrittenResponse;
    }
 
    /// <summary>
    /// Returns possibly update document position info and provisional edit (if any)
    /// </summary>
    /// <remarks>
    /// Provisional completion happens when typing something like @DateTime. in a document.
    /// In this case the '.' initially is parsed as belonging to HTML. However, we want to
    /// show C# member completion in this case, so we want to make a temporary change to the
    /// generated C# code so that '.' ends up in C#. This method will check for such case,
    /// and provisional completion case is detected, will update position language from HTML
    /// to C# and will return a temporary edit that should be made to the generated document
    /// in order to add the '.' to the generated C# contents.
    /// </remarks>
    public static bool TryGetProvisionalCompletionInfo(
        RazorCodeDocument codeDocument,
        VSInternalCompletionContext completionContext,
        DocumentPositionInfo originalPositionInfo,
        IDocumentMappingService documentMappingService,
        out CompletionPositionInfo result)
    {
        result = default;
 
        if (originalPositionInfo.LanguageKind != RazorLanguageKind.Html ||
            completionContext.TriggerKind != CompletionTriggerKind.TriggerCharacter ||
            completionContext.TriggerCharacter != ".")
        {
            // Invalid provisional completion context
            return false;
        }
 
        if (originalPositionInfo.Position.Character == 0)
        {
            // We're at the start of line. Can't have provisional completions here.
            return false;
        }
 
        var previousCharacterPositionInfo = documentMappingService.GetPositionInfo(codeDocument, originalPositionInfo.HostDocumentIndex - 1);
 
        if (previousCharacterPositionInfo.LanguageKind != RazorLanguageKind.CSharp)
        {
            return false;
        }
 
        var previousPosition = previousCharacterPositionInfo.Position;
 
        // Edit the CSharp projected document to contain a '.'. This allows C# completion to provide valid
        // completion items for moments when a user has typed a '.' that's typically interpreted as Html.
        var addProvisionalDot = LspFactory.CreateTextEdit(previousPosition, ".");
 
        var provisionalPositionInfo = new DocumentPositionInfo(
            RazorLanguageKind.CSharp,
            LspFactory.CreatePosition(
                previousPosition.Line,
                previousPosition.Character + 1),
            previousCharacterPositionInfo.HostDocumentIndex + 1);
 
        result = new CompletionPositionInfo(addProvisionalDot, provisionalPositionInfo, ShouldIncludeDelegationSnippets: false);
        return true;
    }
 
    public static bool ShouldIncludeSnippets(RazorCodeDocument razorCodeDocument, int absoluteIndex)
    {
        var root = razorCodeDocument.GetRequiredSyntaxRoot();
 
        var token = root.FindToken(absoluteIndex, includeWhitespace: false);
        if (token.Kind == SyntaxKind.EndOfFile &&
            token.GetPreviousToken().Parent is { } parent &&
            parent.FirstAncestorOrSelf<RazorSyntaxNode>(RazorSyntaxFacts.IsAnyStartTag) is not null)
        {
            // If we're at the end of the file, we check if the previous token is part of a start tag, because the parser
            // treats whitespace at the end different. eg with "<$$[EOF]" or "<div $$", the EndOfFile won't be seen as being
            // in the tag, so without this special casing snippets would be shown.
            return false;
        }
 
        var node = token.Parent;
        var startOrEndTag = node?.FirstAncestorOrSelf<RazorSyntaxNode>(n => RazorSyntaxFacts.IsAnyStartTag(n) || RazorSyntaxFacts.IsAnyEndTag(n));
 
        if (startOrEndTag is null)
        {
            if (IsInScriptOrStyleOrHtmlComment(node))
            {
                // If we're in a style, script, or HTML comment block, we don't want to include HTML snippets.
                return false;
            }
 
            return token.Kind is not (SyntaxKind.OpenAngle or SyntaxKind.CloseAngle);
        }
 
        if (startOrEndTag.Span.Start == absoluteIndex)
        {
            // We're at the start of the tag, we should include snippets. This is the case for things like $$<div></div> or <div>$$</div>, since the
            // index is right associative to the token when using FindToken.
            return true;
        }
 
        return !startOrEndTag.Span.Contains(absoluteIndex);
 
        static bool IsInScriptOrStyleOrHtmlComment(AspNetCore.Razor.Language.Syntax.SyntaxNode? initialNode)
        {
            for (var node = initialNode; node != null; node = node.Parent)
            {
                if (node is BaseMarkupElementSyntax elementNode)
                {
                    if (RazorSyntaxFacts.IsScriptOrStyleBlock(elementNode))
                    {
                        return true;
                    }
 
                    // If we're in an element but it's not a script or style block, stop looking
                    break;
                }
                else if (node is MarkupCommentBlockSyntax commentNode)
                {
                    return true;
                }
            }
 
            return false;
        }
    }
 
    public static object? GetOriginalCompletionItemData(
        VSInternalCompletionItem requestCompletionItem,
        VSInternalCompletionList containingCompletionList,
        object? originalCompletionListData)
    {
        var requestLabel = requestCompletionItem.Label;
        var requestKind = requestCompletionItem.Kind;
        var originalDelegatedCompletionItem = containingCompletionList.Items.FirstOrDefault(
            completionItem => string.Equals(requestLabel, completionItem.Label, StringComparison.Ordinal)
                && requestKind == completionItem.Kind);
 
        if (originalDelegatedCompletionItem is null)
        {
            return null;
        }
 
        object? originalData;
 
        // If the data was merged to combine resultId with original data, undo that merge and set the data back
        // to what it originally was for the delegated request
        if (CompletionListMerger.TrySplit(originalDelegatedCompletionItem.Data, out var splitData) && splitData.Length == 2)
        {
            originalData = splitData[1];
        }
        else
        {
            originalData = originalDelegatedCompletionItem.Data ?? originalCompletionListData;
        }
 
        return originalData;
    }
 
    public static async Task<VSInternalCompletionItem> FormatCSharpCompletionItemAsync(
        VSInternalCompletionItem resolvedCompletionItem,
        DocumentContext documentContext,
        RazorFormattingOptions options,
        IRazorFormattingService formattingService,
        IDocumentMappingService documentMappingService,
        bool supportsVisualStudioExtensions,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        // In VS Code, Roslyn does resolve via a custom command. Thats fine, but we have to modify the text edit sitting within it,
        // rather than the one LSP knows about.
        if (resolvedCompletionItem.Command is { CommandIdentifier: Constants.CompleteComplexEditCommand, Arguments: var args })
        {
            // In LSP case, command parameters will be JsonElement objects and will need to be deserialized first
            if (args is [JsonElement textDocumentIdentifierData, JsonElement complexEditData, _, _])
            {
                args[0] = textDocumentIdentifierData.Deserialize<TextDocumentIdentifier>() ?? args[0];
                args[1] = complexEditData.Deserialize<TextEdit>() ?? args[1];
            }
 
            // In cohosting case, command parameters will be of the correct types (or deserialized by now in LSP case)
            if (args is [TextDocumentIdentifier, TextEdit complexEdit, _, int nextCursorPosition])
            {
                var formattedTextEdit = await FormatTextEditsAsync([complexEdit], documentContext, options, formattingService, cancellationToken).ConfigureAwait(false);
                if (formattedTextEdit is null)
                {
                    resolvedCompletionItem.Command = null;
                }
                else
                {
                    args[0] = new TextDocumentIdentifier()
                    {
                        DocumentUri = new(documentContext.Uri),
                    };
                    args[1] = formattedTextEdit;
                    if (nextCursorPosition >= 0)
                    {
                        // nextCursorPosition is where VS Code will navigate to, so we translate it to our document, or set to 0 to do nothing.
                        var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
                        args[3] = documentMappingService.TryMapToRazorDocumentPosition(codeDocument.GetRequiredCSharpDocument(), nextCursorPosition, out _, out nextCursorPosition)
                            ? nextCursorPosition
                            : 0;
                    }
                }
            }
            else
            {
                logger.LogError($"Unexpected arguments for command '{Constants.CompleteComplexEditCommand}': Expected: [TextDocumentIdentifier, TextEdit, _, int], Actual: {GetArgumentTypesLogString(resolvedCompletionItem)}");
                Debug.Fail("Unexpected arguments for Roslyn complex edit command. Have they changed things?");
            }
        }
        else if (resolvedCompletionItem.Command is not null)
        {
            logger.LogError($"Unsupported command for Razor document: {resolvedCompletionItem.Command.CommandIdentifier}.");
            Debug.Fail("Unexpected command. Do we need to add something to support a new feature?");
        }
 
        if (resolvedCompletionItem.TextEdit is not null &&
            supportsVisualStudioExtensions &&
            resolvedCompletionItem.VsResolveTextEditOnCommit)
        {
            if (resolvedCompletionItem.TextEdit.Value.TryGetFirst(out var textEdit))
            {
                var formattedTextChange = await FormatTextEditsAsync([textEdit], documentContext, options, formattingService, cancellationToken).ConfigureAwait(false);
                if (formattedTextChange is not null)
                {
                    resolvedCompletionItem.TextEdit = formattedTextChange;
                }
            }
            else
            {
                // TODO: Handle InsertReplaceEdit type
                // https://github.com/dotnet/razor/issues/8829
                Debug.Fail("Unsupported edit type.");
            }
        }
 
        if (resolvedCompletionItem.AdditionalTextEdits is not null)
        {
            var formattedTextChange = await FormatTextEditsAsync(resolvedCompletionItem.AdditionalTextEdits, documentContext, options, formattingService, cancellationToken).ConfigureAwait(false);
            resolvedCompletionItem.AdditionalTextEdits = formattedTextChange is { } change ? [change] : null;
        }
 
        return resolvedCompletionItem;
 
        static string GetArgumentTypesLogString(VSInternalCompletionItem resolvedCompletionItem)
        {
            if (resolvedCompletionItem.Command?.Arguments is { } args)
            {
                return "[" + string.Join(", ", args.Select(a => a.GetType().Name)) + "]";
            }
 
            return "null";
        }
    }
 
    private static async Task<TextEdit?> FormatTextEditsAsync(TextEdit[] textEdits, DocumentContext documentContext, RazorFormattingOptions options, IRazorFormattingService formattingService, CancellationToken cancellationToken)
    {
        var sourceText = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
        var csharpSourceText = await documentContext.GetCSharpSourceTextAsync(cancellationToken).ConfigureAwait(false);
 
        var changes = textEdits.SelectAsArray(csharpSourceText.GetTextChange);
        var formattedTextChange = await formattingService.TryGetCSharpSnippetFormattingEditAsync(
            documentContext,
            changes,
            options,
            cancellationToken).ConfigureAwait(false);
 
        return formattedTextChange is { } change ? sourceText.GetTextEdit(change) : null;
    }
}