File: Internal\Analyzer\AbstractCopilotCodeAnalysisService.cs
Web Access
Project: src\src\EditorFeatures\ExternalAccess\Copilot\Microsoft.CodeAnalysis.ExternalAccess.Copilot.csproj (Microsoft.CodeAnalysis.ExternalAccess.Copilot)
// 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.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Copilot;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.ExternalAccess.Copilot.Internal.Analyzer;
 
/// <summary>
/// Copilot code analysis service that coordinates triggering Copilot code analysis
/// in the background for a given document.
/// This service caches the computed Copilot suggestion diagnostics by method body to ensure that
/// we do not perform duplicate analysis calls.
/// Additionally, it performs all the option checks and Copilot service availability checks
/// to determine if we should skip analysis or not.
/// </summary>
internal abstract class AbstractCopilotCodeAnalysisService(IDiagnosticsRefresher diagnosticsRefresher) : ICopilotCodeAnalysisService
{
    // The _diagnosticsCache is a cache for computed diagnostics via `AnalyzeDocumentAsync`.
    // Each document maps to a dictionary, which in tern maps a prompt title to a list of existing Diagnostics and a boolean flag.
    // The list of diagnostics represents the diagnostics computed for the document under the given prompt title,
    // the boolean flag indicates whether the diagnostics result is for the entire document.
    // This cache is used to avoid duplicate analysis calls by storing the computed diagnostics for each document and prompt title.
    private readonly ConditionalWeakTable<Document, ConcurrentDictionary<string, (ImmutableArray<Diagnostic> Diagnostics, bool IsCompleteResult)>> _diagnosticsCache = new();
 
    protected abstract Task<bool> IsAvailableCoreAsync(CancellationToken cancellationToken);
    protected abstract Task<ImmutableArray<string>> GetAvailablePromptTitlesCoreAsync(Document document, CancellationToken cancellationToken);
    protected abstract Task<ImmutableArray<Diagnostic>> AnalyzeDocumentCoreAsync(Document document, TextSpan? span, string promptTitle, CancellationToken cancellationToken);
    protected abstract Task<ImmutableArray<Diagnostic>> GetCachedDiagnosticsCoreAsync(Document document, string promptTitle, CancellationToken cancellationToken);
    protected abstract Task StartRefinementSessionCoreAsync(Document oldDocument, Document newDocument, Diagnostic? primaryDiagnostic, CancellationToken cancellationToken);
    protected abstract Task<string> GetOnTheFlyDocsCoreAsync(string symbolSignature, ImmutableArray<string> declarationCode, string language, CancellationToken cancellationToken);
    protected abstract Task<bool> IsFileExcludedCoreAsync(string filePath, CancellationToken cancellationToken);
 
    public Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
        => IsAvailableCoreAsync(cancellationToken);
 
    public async Task<ImmutableArray<string>> GetAvailablePromptTitlesAsync(Document document, CancellationToken cancellationToken)
    {
        if (document.GetLanguageService<ICopilotOptionsService>() is not { } service)
            return [];
 
        if (!await service.IsCodeAnalysisOptionEnabledAsync().ConfigureAwait(false))
            return [];
 
        return await GetAvailablePromptTitlesCoreAsync(document, cancellationToken).ConfigureAwait(false);
    }
 
    private static async Task<bool> ShouldSkipAnalysisAsync(Document document, CancellationToken cancellationToken)
    {
        if (document.GetLanguageService<ICopilotOptionsService>() is not { } service)
            return true;
 
        if (!await service.IsCodeAnalysisOptionEnabledAsync().ConfigureAwait(false))
            return true;
 
        if (await document.IsGeneratedCodeAsync(cancellationToken).ConfigureAwait(false))
            return true;
 
        return false;
    }
 
    public async Task AnalyzeDocumentAsync(Document document, TextSpan? span, string promptTitle, CancellationToken cancellationToken)
    {
        if (await ShouldSkipAnalysisAsync(document, cancellationToken).ConfigureAwait(false))
            return;
 
        if (FullDocumentDiagnosticsCached(document, promptTitle))
            return;
 
        if (!await IsAvailableAsync(cancellationToken).ConfigureAwait(false))
            return;
 
        var isFullDocumentAnalysis = !span.HasValue;
        var diagnostics = await AnalyzeDocumentCoreAsync(document, span, promptTitle, cancellationToken).ConfigureAwait(false);
 
        cancellationToken.ThrowIfCancellationRequested();
 
        CacheAndRefreshDiagnosticsIfNeeded(document, promptTitle, diagnostics, isFullDocumentAnalysis);
    }
 
    private bool FullDocumentDiagnosticsCached(Document document, string promptTitle)
        => TryGetDiagnosticsFromCache(document, promptTitle, out _, out var isCompleteResult) && isCompleteResult;
 
    private bool TryGetDiagnosticsFromCache(Document document, string promptTitle, out ImmutableArray<Diagnostic> diagnostics, out bool isCompleteResult)
    {
        if (_diagnosticsCache.TryGetValue(document, out var existingDiagnosticsMap)
            && existingDiagnosticsMap.TryGetValue(promptTitle, out var value))
        {
            diagnostics = value.Diagnostics;
            isCompleteResult = value.IsCompleteResult;
            return true;
        }
 
        diagnostics = [];
        isCompleteResult = false;
        return false;
    }
 
    private void CacheAndRefreshDiagnosticsIfNeeded(Document document, string promptTitle, ImmutableArray<Diagnostic> diagnostics, bool isCompleteResult)
    {
        lock (_diagnosticsCache)
        {
            // Nothing to be updated if we have already cached complete diagnostic result.
            if (FullDocumentDiagnosticsCached(document, promptTitle))
                return;
 
            // No cancellation from here.
            var diagnosticsMap = _diagnosticsCache.GetOrCreateValue(document);
            diagnosticsMap[promptTitle] = (diagnostics, isCompleteResult);
        }
 
        // For LSP pull diagnostics, request a diagnostic workspace refresh.
        // We will include the cached copilot diagnostics from this service as part of that pull request.
        diagnosticsRefresher.RequestWorkspaceRefresh();
    }
 
    public async Task<ImmutableArray<Diagnostic>> GetCachedDocumentDiagnosticsAsync(Document document, TextSpan? span, ImmutableArray<string> promptTitles, CancellationToken cancellationToken)
    {
        if (await ShouldSkipAnalysisAsync(document, cancellationToken).ConfigureAwait(false))
            return [];
 
        using var _1 = ArrayBuilder<Diagnostic>.GetInstance(out var diagnostics);
 
        foreach (var promptTitle in promptTitles)
        {
            // First, we try to fetch the diagnostics from our local cache.
            // If we haven't cached the diagnostics locally, then we fetch the cached diagnostics
            // from the core copilot analyzer. Subsequently, we update our local cache to store
            // these diagnostics so that future diagnostic requests can be served quickly.
            // We also raise diagnostic refresh requests for all our diagnostic clients when
            // updating our local diagnostics cache.
 
            if (TryGetDiagnosticsFromCache(document, promptTitle, out var existingDiagnostics, out _))
            {
                diagnostics.AddRange(existingDiagnostics);
            }
            else
            {
                var cachedDiagnostics = await GetCachedDiagnosticsCoreAsync(document, promptTitle, cancellationToken).ConfigureAwait(false);
                diagnostics.AddRange(cachedDiagnostics);
                CacheAndRefreshDiagnosticsIfNeeded(document, promptTitle, cachedDiagnostics, isCompleteResult: false);
            }
        }
 
        if (span.HasValue)
            return await GetDiagnosticsIntersectWithSpanAsync(document, diagnostics, span.Value, cancellationToken).ConfigureAwait(false);
 
        return diagnostics.ToImmutable();
    }
 
    protected virtual Task<ImmutableArray<Diagnostic>> GetDiagnosticsIntersectWithSpanAsync(Document document, IReadOnlyList<Diagnostic> diagnostics, TextSpan span, CancellationToken cancellationToken)
    {
        return Task.FromResult(diagnostics.WhereAsArray((diagnostic, _) => diagnostic.Location.SourceSpan.IntersectsWith(span), state: (object)null));
    }
 
    public async Task StartRefinementSessionAsync(Document oldDocument, Document newDocument, Diagnostic? primaryDiagnostic, CancellationToken cancellationToken)
    {
        if (oldDocument.GetLanguageService<ICopilotOptionsService>() is not { } service)
            return;
 
        if (await service.IsRefineOptionEnabledAsync().ConfigureAwait(false))
            await StartRefinementSessionCoreAsync(oldDocument, newDocument, primaryDiagnostic, cancellationToken).ConfigureAwait(false);
    }
 
    public async Task<string> GetOnTheFlyDocsAsync(string symbolSignature, ImmutableArray<string> declarationCode, string language, CancellationToken cancellationToken)
    {
        if (!await IsAvailableAsync(cancellationToken).ConfigureAwait(false))
            return string.Empty;
 
        return await GetOnTheFlyDocsCoreAsync(symbolSignature, declarationCode, language, cancellationToken).ConfigureAwait(false);
    }
 
    public async Task<bool> IsFileExcludedAsync(string filePath, CancellationToken cancellationToken)
    {
        if (!await IsAvailableAsync(cancellationToken).ConfigureAwait(false))
            return false;
 
        return await IsFileExcludedCoreAsync(filePath, cancellationToken).ConfigureAwait(false);
    }
}