File: Copilot\RoslynProposalAdjusterProvider.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures_vqux3thj_wpftmp.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// 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.ComponentModel.Composition;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Editor;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Remote;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Language.Proposals;
using Microsoft.VisualStudio.Utilities;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Copilot;
 
// The entire AdjusterProvider api is marked as obsolete since this is a preview API.  So we do the same here as well.
[Export(typeof(ProposalAdjusterProviderBase))]
[Obsolete("This is a preview api and subject to change")]
[ContentType(ContentTypeNames.CSharpContentType)]
[ContentType(ContentTypeNames.VisualBasicContentType)]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed class RoslynProposalAdjusterProvider() : ProposalAdjusterProviderBase
{
    public override Task<ProposalBase> AdjustProposalBeforeDisplayAsync(ProposalBase proposal, string providerName, CancellationToken cancellationToken)
        => AdjustProposalAsync(proposal, providerName, before: true, cancellationToken);
 
    public override Task<ProposalBase> AdjustProposalAfterAcceptAsync(ProposalBase proposal, string providerName, CancellationToken cancellationToken)
        => AdjustProposalAsync(proposal, providerName, before: false, cancellationToken);
 
    private static void SetDefaultTelemetryProperties(Dictionary<string, object?> map, string providerName, bool before, TimeSpan elapsedTime)
    {
        // Common properties that all adjustments will log.
        map["ProviderName"] = providerName;
        map["AdjustProposalBeforeDisplay"] = before;
        map["ComputationTime"] = elapsedTime.TotalMilliseconds.ToString("G17", CultureInfo.InvariantCulture);
    }
 
    private async Task<ProposalBase> AdjustProposalAsync(
        ProposalBase proposal, string providerName, bool before, CancellationToken cancellationToken)
    {
        var stopwatch = SharedStopwatch.StartNew();
 
        // Ensure we're only operating on one solution.  It makes the logic much simpler, as we don't have to
        // worry about edits that touch multiple solutions.
        var (solution, failureReason) = CopilotEditorUtilities.TryGetAffectedSolution(proposal);
        if (solution is null)
        {
            // If we can't find a solution, then we can't adjust the proposal.  Log telemetry and return the original proposal.
            Logger.LogBlock(FunctionId.Copilot_AdjustProposal, KeyValueLogMessage.Create(static (d, args) =>
            {
                var (providerName, before, failureReason, elapsedTime) = args;
                SetDefaultTelemetryProperties(d, providerName, before, elapsedTime);
                d["SolutionAcquisitionFailure"] = failureReason;
            },
            args: (providerName, before, failureReason, stopwatch.Elapsed)),
            cancellationToken).Dispose();
 
            return proposal;
        }
 
        return await AdjustProposalAsync(
            solution, proposal, providerName, before, stopwatch, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<ProposalBase> AdjustProposalAsync(
        Solution solution,
        ProposalBase proposal,
        string providerName,
        bool before,
        SharedStopwatch stopwatch,
        CancellationToken cancellationToken)
    {
        try
        {
            var newProposal = await AdjustProposalAsync(
                solution, proposal, cancellationToken).ConfigureAwait(false);
 
            // Report telemetry if we were or were not able to adjust the proposal.
            Logger.LogBlock(FunctionId.Copilot_AdjustProposal, KeyValueLogMessage.Create(static (d, args) =>
            {
                var (providerName, before, proposal, newProposal, elapsedTime) = args;
 
                // If we (roslyn) were able to come up with *any* edits we wanted to adjust the proposal with or not.
                var adjustmentsProposed = newProposal != null;
 
                newProposal ??= proposal;
 
                // Whether or not the Proposal system accepted the edits we proposed.  It can reject them for a variety of reasons,
                // for example, if it thinks they would interfere with the caret, or if edits intersect other edits.
                var adjustmentsAccepted = newProposal != proposal;
 
                SetDefaultTelemetryProperties(d, providerName, before, elapsedTime);
 
                d["AdjustmentsProposed"] = adjustmentsProposed;
                d["AdjustmentsAccepted"] = adjustmentsAccepted;
 
                // Record how many new edits were made to the proposal.  Expectation is that this is commonly only 1,
                // but we want to see how that potentially changes over time, especially as we add more adjusters.
                d["AdjustmentsCount"] = newProposal.Edits.Count - proposal.Edits.Count;
            },
            args: (providerName, before, proposal, newProposal, stopwatch.Elapsed)),
            cancellationToken).Dispose();
 
            return newProposal ?? proposal;
        }
        catch (Exception ex) when (ReportFailureTelemetry(ex))
        {
            // Don't leak out any exceptions.  We report them as telemetry (and/or NFW), but we don't want to block the
            // user getting the original proposal.
            return proposal;
        }
 
        bool ReportFailureTelemetry(Exception ex)
        {
            // If it's not cancellation, report an NFW to track down our bug.
            if (ex is not OperationCanceledException)
                FatalError.ReportAndCatch(ex);
 
            Logger.LogBlock(FunctionId.Copilot_AdjustProposal, KeyValueLogMessage.Create(static (d, args) =>
            {
                var (providerName, before, ex, elapsedTime) = args;
                SetDefaultTelemetryProperties(d, providerName, before, elapsedTime);
                d["AdjustmentsProposed"] = false;
 
                if (ex is OperationCanceledException)
                {
                    d["Canceled"] = true;
                }
                else
                {
                    // Will be able to get a count of how often this happens.  NFWs can be used to track down the issue.
                    d["Failed"] = true;
                }
            },
            args: (providerName, before, ex, stopwatch.Elapsed)),
            cancellationToken).Dispose();
 
            return true;
        }
    }
 
    /// <summary>
    /// Returns <see langword="null"/> if we didn't make any adjustments to the proposal.  Otherwise, returns the attempted
    /// adjusted proposal based on the edits we tried to make.  Note that this does not guarantee that the edits were successfully
    /// applied to the original edits.  The <see cref="Proposal"/> system may reject them based on their own criteria.
    /// </summary>
    private async Task<ProposalBase?> AdjustProposalAsync(
        Solution solution, ProposalBase proposal, CancellationToken cancellationToken)
    {
        // We're potentially making multiple calls to oop here.  So keep a session alive to avoid
        // resyncing the solution and recomputing compilations.
        using var _1 = await RemoteKeepAliveSession.CreateAsync(solution, cancellationToken).ConfigureAwait(false);
        using var _2 = PooledObjects.ArrayBuilder<ProposedEdit>.GetInstance(out var finalEdits);
 
        var adjustmentsProposed = false;
        foreach (var editGroup in proposal.Edits.GroupBy(e => e.Span.Snapshot))
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            var snapshot = editGroup.Key;
            var document = snapshot.GetOpenDocumentInCurrentContextWithChanges();
 
            // Checked in TryGetAffectedSolution
            Contract.ThrowIfNull(document);
 
            var proposalAdjusterService = document.Project.Solution.Services.GetRequiredService<ICopilotProposalAdjusterService>();
            var proposedEdits = await proposalAdjusterService.TryAdjustProposalAsync(
                document, CopilotEditorUtilities.TryGetNormalizedTextChanges(editGroup), cancellationToken).ConfigureAwait(false);
 
            if (proposedEdits.IsDefault)
            {
                // No changes were made to the proposal.  Just add the original edits.
                finalEdits.AddRange(editGroup);
            }
            else
            {
                // Changes were made to the proposal.  Add the new edits.
                adjustmentsProposed = true;
                foreach (var proposedEdit in proposedEdits)
                {
                    finalEdits.Add(new ProposedEdit(
                        new(snapshot, proposedEdit.Span.ToSpan()),
                        proposedEdit.NewText!));
                }
            }
        }
 
        // No adjustments were made.  Don't touch anything.
        if (!adjustmentsProposed)
            return null;
 
        // We have some changes we want to to make to the proposal.  See if the proposal system allows us merging
        // those changes in.  Note: we should generally always be producing edits that are safe to merge in.  However,
        // as we do not control this code, we cannot guarantee this.  Telemetry will let us know how often this happens
        // and if there's something we need to look into.
        return Proposal.TryCreateProposal(proposal, finalEdits);
    }
}