File: Completion\RemoteCompletionService.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Remote.Razor\Microsoft.CodeAnalysis.Remote.Razor.csproj (Microsoft.CodeAnalysis.Remote.Razor)
// 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.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor.Completion;
using Microsoft.CodeAnalysis.Razor.Completion.Delegation;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Protocol.Completion;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Razor.Telemetry;
using Microsoft.CodeAnalysis.Razor.Workspaces.Settings;
using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Text;
using Response = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse<Roslyn.LanguageServer.Protocol.RazorVSInternalCompletionList?>;
using CompletionResponse = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse<Microsoft.CodeAnalysis.Razor.Protocol.Completion.CompletionResult>;
 
namespace Microsoft.CodeAnalysis.Remote.Razor;
 
internal sealed class RemoteCompletionService(in ServiceArgs args) : RazorDocumentServiceBase(in args), IRemoteCompletionService
{
    internal sealed class Factory : FactoryBase<IRemoteCompletionService>
    {
        protected override IRemoteCompletionService CreateService(in ServiceArgs args)
            => new RemoteCompletionService(in args);
    }
 
    private readonly RazorCompletionListProvider _razorCompletionListProvider = args.ExportProvider.GetExportedValue<RazorCompletionListProvider>();
    private readonly CompletionListCache _completionListCache = args.ExportProvider.GetExportedValue<CompletionListCache>();
    private readonly CompletionListCacheWrapperProvder _cacheWrapperProvider = args.ExportProvider.GetExportedValue<CompletionListCacheWrapperProvder>();
    private readonly IClientCapabilitiesService _clientCapabilitiesService = args.ExportProvider.GetExportedValue<IClientCapabilitiesService>();
    private readonly CompletionTriggerAndCommitCharacters _triggerAndCommitCharacters = args.ExportProvider.GetExportedValue<CompletionTriggerAndCommitCharacters>();
    private readonly IRazorFormattingService _formattingService = args.ExportProvider.GetExportedValue<IRazorFormattingService>();
    private readonly IDocumentMappingService _documentMappingService = args.ExportProvider.GetExportedValue<IDocumentMappingService>();
    private readonly ITelemetryReporter _telemetryReporter = args.ExportProvider.GetExportedValue<ITelemetryReporter>();
    private readonly IClientSettingsManager _clientSettingsManager = args.ExportProvider.GetExportedValue<IClientSettingsManager>();
 
    public ValueTask<CompletionPositionInfo?> GetPositionInfoAsync(
        JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo,
        JsonSerializableDocumentId documentId,
        VSInternalCompletionContext completionContext,
        Position position,
        CancellationToken cancellationToken)
        => RunServiceAsync(
            solutionInfo,
            documentId,
            context => GetPositionInfoAsync(context, completionContext, position, cancellationToken),
            cancellationToken);
 
    private async ValueTask<CompletionPositionInfo?> GetPositionInfoAsync(
        RemoteDocumentContext remoteDocumentContext,
        VSInternalCompletionContext completionContext,
        Position position,
        CancellationToken cancellationToken)
    {
        var sourceText = await remoteDocumentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
        if (!sourceText.TryGetAbsoluteIndex(position, out var index))
        {
            return null;
        }
 
        var codeDocument = await remoteDocumentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
 
        var positionInfo = GetPositionInfo(codeDocument, index);
 
        if (positionInfo.LanguageKind != RazorLanguageKind.Razor
            && DelegatedCompletionHelper.TryGetProvisionalCompletionInfo(
                codeDocument, completionContext, positionInfo, DocumentMappingService, out var provisionalCompletionInfo))
        {
            return provisionalCompletionInfo with { ShouldIncludeDelegationSnippets = false };
        }
 
        var shouldIncludeSnippets = positionInfo.LanguageKind == RazorLanguageKind.Html
            && DelegatedCompletionHelper.ShouldIncludeSnippets(codeDocument, index);
 
        return new CompletionPositionInfo(ProvisionalTextEdit: null, positionInfo, shouldIncludeSnippets);
    }
 
    public ValueTask<CompletionResponse> GetCompletionAsync(
        JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo,
        JsonSerializableDocumentId documentId,
        CompletionPositionInfo positionInfo,
        VSInternalCompletionContext completionContext,
        RazorCompletionOptions razorCompletionOptions,
        Guid correlationId,
        CancellationToken cancellationToken)
        => RunServiceAsync(
            solutionInfo,
            documentId,
            context => GetCompletionAsync(
                context,
                positionInfo,
                completionContext,
                razorCompletionOptions,
                correlationId,
                cancellationToken),
            cancellationToken);
 
    private async ValueTask<CompletionResponse> GetCompletionAsync(
        RemoteDocumentContext remoteDocumentContext,
        CompletionPositionInfo positionInfo,
        VSInternalCompletionContext completionContext,
        RazorCompletionOptions razorCompletionOptions,
        Guid correlationId,
        CancellationToken cancellationToken)
    {
        var documentPositionInfo = positionInfo.DocumentPositionInfo;
 
        var isCSharpTrigger = documentPositionInfo.LanguageKind == RazorLanguageKind.CSharp &&
                              _triggerAndCommitCharacters.IsValidCSharpTrigger(completionContext);
 
        var isRazorTrigger = _triggerAndCommitCharacters.IsValidRazorTrigger(completionContext);
 
        if (!isCSharpTrigger && !isRazorTrigger)
        {
            // We don't have a valid trigger, so we can't provide completions.
            return CompletionResults.CallHtml;
        }
 
        var documentSnapshot = remoteDocumentContext.Snapshot;
 
        var codeDocument = await documentSnapshot.GetGeneratedOutputAsync(cancellationToken).ConfigureAwait(false);
 
        RazorVSInternalCompletionList? csharpCompletionList = null;
        if (isCSharpTrigger)
        {
            var mappedPosition = documentPositionInfo.Position;
 
            var csharpGeneratedDocument = await GetCSharpGeneratedDocumentAsync(
                documentSnapshot, positionInfo.ProvisionalTextEdit, cancellationToken).ConfigureAwait(false);
 
            csharpCompletionList = await GetCSharpCompletionAsync(
                csharpGeneratedDocument,
                codeDocument,
                documentPositionInfo.HostDocumentIndex,
                mappedPosition,
                completionContext,
                razorCompletionOptions,
                correlationId,
                positionInfo.ProvisionalTextEdit,
                cancellationToken)
                .ConfigureAwait(false);
        }
 
        RazorVSInternalCompletionList? razorCompletionList = null;
        var needsHtmlDependentPhase = false;
 
        if (isRazorTrigger)
        {
            (razorCompletionList, needsHtmlDependentPhase) = _razorCompletionListProvider.GetCompletionList(
                codeDocument,
                documentPositionInfo.HostDocumentIndex,
                completionContext,
                _clientCapabilitiesService.ClientCapabilities,
                razorCompletionOptions);
        }
 
        return CompletionResults.Create(
            CompletionListMerger.Merge(razorCompletionList, csharpCompletionList),
            needsHtmlDependentPhase);
    }
 
    public ValueTask<Response> GetHtmlDependentCompletionsAsync(
        JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo,
        JsonSerializableDocumentId documentId,
        CompletionPositionInfo positionInfo,
        VSInternalCompletionContext completionContext,
        RazorCompletionOptions razorCompletionOptions,
        string[] htmlLabels,
        CancellationToken cancellationToken)
        => RunServiceAsync(
            solutionInfo,
            documentId,
            context => GetHtmlDependentCompletionsAsync(
                context,
                positionInfo,
                completionContext,
                razorCompletionOptions,
                htmlLabels,
                cancellationToken),
            cancellationToken);
 
    private async ValueTask<Response> GetHtmlDependentCompletionsAsync(
        RemoteDocumentContext remoteDocumentContext,
        CompletionPositionInfo positionInfo,
        VSInternalCompletionContext completionContext,
        RazorCompletionOptions razorCompletionOptions,
        string[] htmlLabels,
        CancellationToken cancellationToken)
    {
        var documentPositionInfo = positionInfo.DocumentPositionInfo;
 
        if (!_triggerAndCommitCharacters.IsValidRazorTrigger(completionContext))
        {
            return Response.CallHtml;
        }
 
        var documentSnapshot = remoteDocumentContext.Snapshot;
        var codeDocument = await documentSnapshot.GetGeneratedOutputAsync(cancellationToken).ConfigureAwait(false);
 
        using var _ = HashSetPool<string>.GetPooledObject(out var existingCompletions);
        existingCompletions.UnionWith(htmlLabels);
 
        var razorCompletionList = _razorCompletionListProvider.GetHtmlDependentCompletionList(
            codeDocument,
            documentPositionInfo.HostDocumentIndex,
            completionContext,
            _clientCapabilitiesService.ClientCapabilities,
            razorCompletionOptions,
            existingCompletions);
 
        if (razorCompletionList is null)
        {
            return Response.CallHtml;
        }
 
        return Response.Results(razorCompletionList);
    }
 
    private async ValueTask<RazorVSInternalCompletionList?> GetCSharpCompletionAsync(
        SourceGeneratedDocument generatedDocument,
        RazorCodeDocument codeDocument,
        int documentIndex,
        Position mappedPosition,
        CompletionContext completionContext,
        RazorCompletionOptions razorCompletionOptions,
        Guid correlationId,
        TextEdit? provisionalTextEdit,
        CancellationToken cancellationToken)
    {
        var clientCapabilities = _clientCapabilitiesService.ClientCapabilities;
        if (clientCapabilities.TextDocument?.Completion is not { } completionSetting)
        {
            Debug.Fail("Unable to convert VS to Roslyn LSP completion setting");
            return null;
        }
 
        var mappedLinePosition = mappedPosition.ToLinePosition();
 
        VSInternalCompletionList? completionList = null;
        using (_telemetryReporter.TrackLspRequest(Methods.TextDocumentCompletionName, Constants.ExternalAccessServerName, TelemetryThresholds.CompletionSubLSPTelemetryThreshold, correlationId))
        {
            completionList = await ExternalAccess.Razor.Cohost.Handlers.Completion.GetCompletionListAsync(
                generatedDocument,
                mappedLinePosition,
                completionContext,
                clientCapabilities.SupportsVisualStudioExtensions,
                completionSetting,
                _cacheWrapperProvider.GetCache(),
                cancellationToken)
                .ConfigureAwait(false);
        }
 
        if (completionList 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()
            {
                Items = [],
                IsIncomplete = true
            };
        }
 
        var rewrittenResponse = DelegatedCompletionHelper.RewriteCSharpResponse(
            new(completionList),
            documentIndex,
            codeDocument,
            mappedPosition,
            razorCompletionOptions);
 
        var resolutionContext = new DelegatedCompletionResolutionContext(RazorLanguageKind.CSharp, rewrittenResponse.Data ?? rewrittenResponse.ItemDefaults?.Data, provisionalTextEdit);
        var resultId = _completionListCache.Add(rewrittenResponse, resolutionContext);
        rewrittenResponse.SetResultId(resultId, clientCapabilities);
 
        return rewrittenResponse;
    }
 
    private static async Task<SourceGeneratedDocument> GetCSharpGeneratedDocumentAsync(RemoteDocumentSnapshot documentSnapshot, TextEdit? provisionalTextEdit, CancellationToken cancellationToken)
    {
        var generatedDocument = await documentSnapshot.GetGeneratedDocumentAsync(cancellationToken).ConfigureAwait(false);
 
        if (provisionalTextEdit is not null)
        {
            var generatedText = await generatedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false);
            var change = generatedText.GetTextChange(provisionalTextEdit);
            generatedText = generatedText.WithChanges([change]);
            generatedDocument = (SourceGeneratedDocument)generatedDocument.WithText(generatedText);
        }
 
        return generatedDocument;
    }
 
    public ValueTask<VSInternalCompletionItem> ResolveCompletionItemAsync(
        JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo,
        JsonSerializableDocumentId documentId,
        VSInternalCompletionItem request,
        CancellationToken cancellationToken)
        => RunServiceAsync(
            solutionInfo,
            documentId,
            context => ResolveCompletionItemAsync(context, request, cancellationToken),
            cancellationToken);
 
    private ValueTask<VSInternalCompletionItem> ResolveCompletionItemAsync(
        RemoteDocumentContext context,
        VSInternalCompletionItem request,
        CancellationToken cancellationToken)
    {
        if (!_completionListCache.TryGetOriginalRequestData(request, out var containingCompletionList, out var originalRequestContext))
        {
            // Couldn't find an associated completion list
            return new(request);
        }
 
        if (originalRequestContext is DelegatedCompletionResolutionContext resolutionContext)
        {
            return ResolveCSharpCompletionItemAsync(context, request, containingCompletionList, resolutionContext, cancellationToken);
        }
        else if (originalRequestContext is RazorCompletionResolveContext razorResolutionContext)
        {
            return ResolveRazorCompletionItemAsync(context, request, razorResolutionContext, cancellationToken);
        }
 
        // We don't know how to resolve this completion item, so just return it as-is.
        Logger.LogWarning("Did not recognize completion item, so unable to resolve.");
        return new(request);
    }
 
    private async ValueTask<VSInternalCompletionItem> ResolveRazorCompletionItemAsync(RemoteDocumentContext context, VSInternalCompletionItem request, RazorCompletionResolveContext razorResolutionContext, CancellationToken cancellationToken)
    {
        var componentAvailabilityService = new ComponentAvailabilityService(context.Snapshot.ProjectSnapshot.SolutionSnapshot);
 
        var result = await RazorCompletionItemResolver.ResolveAsync(
            request,
            _clientCapabilitiesService.ClientCapabilities,
            componentAvailabilityService,
            razorResolutionContext,
            cancellationToken).ConfigureAwait(false);
 
        // If we couldn't resolve, fall back to what we were passed in
        return result ?? request;
    }
 
    private async ValueTask<VSInternalCompletionItem> ResolveCSharpCompletionItemAsync(RemoteDocumentContext context, VSInternalCompletionItem request, VSInternalCompletionList containingCompletionList, DelegatedCompletionResolutionContext resolutionContext, CancellationToken cancellationToken)
    {
        var oldData = request.Data;
        try
        {
            request.Data = DelegatedCompletionHelper.GetOriginalCompletionItemData(request, containingCompletionList, resolutionContext.OriginalCompletionListData);
 
            // Roslyn expects data to be JsonElement because we're calling into their LSP handler, but we cache their data as its underlying object, so lets
            // make sure to serialize if we need to.
            if (request.Data is not JsonElement)
            {
                request.Data = JsonSerializer.SerializeToElement(request.Data, JsonHelpers.JsonSerializerOptions);
            }
 
            var documentSnapshot = context.Snapshot;
            var generatedDocument = await GetCSharpGeneratedDocumentAsync(documentSnapshot, resolutionContext.ProvisionalTextEdit, cancellationToken).ConfigureAwait(false);
 
            var clientCapabilities = _clientCapabilitiesService.ClientCapabilities;
            var completionListSetting = clientCapabilities.TextDocument?.Completion;
            var result = await ExternalAccess.Razor.Cohost.Handlers.Completion.ResolveCompletionItemAsync(
                request,
                generatedDocument,
                clientCapabilities.SupportsVisualStudioExtensions,
                completionListSetting ?? new(),
                _cacheWrapperProvider.GetCache(),
                cancellationToken).ConfigureAwait(false);
 
            var item = JsonHelpers.Convert<CompletionItem, VSInternalCompletionItem>(result).AssumeNotNull();
 
            var formattingOptions = _clientSettingsManager.GetClientSettings().ToRazorFormattingOptions();
 
            item = await DelegatedCompletionHelper.FormatCSharpCompletionItemAsync(
                item,
                context,
                formattingOptions,
                _formattingService,
                _documentMappingService,
                clientCapabilities.SupportsVisualStudioExtensions,
                Logger,
                cancellationToken).ConfigureAwait(false);
 
            return item;
        }
        finally
        {
            request.Data = oldData; // Restore original data to avoid side effects, as it may have come from the cache
        }
    }
}