File: EditAndContinue\EditAndContinueDocumentAnalysesCache.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// 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.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.EditAndContinue;
 
/// <summary>
/// Calculates and caches results of changed documents analysis. 
/// The work is triggered by an incremental analyzer on idle or explicitly when "continue" operation is executed.
/// Contains analyses of the latest observed document versions.
/// </summary>
internal sealed class EditAndContinueDocumentAnalysesCache(AsyncLazy<ActiveStatementsMap> baseActiveStatements, AsyncLazy<EditAndContinueCapabilities> capabilities)
{
    private readonly object _guard = new();
    private readonly Dictionary<DocumentId, (AsyncLazy<DocumentAnalysisResults> results, Project baseProject, Document document, ImmutableArray<ActiveStatementLineSpan> activeStatementSpans)> _analyses = [];
    private readonly AsyncLazy<ActiveStatementsMap> _baseActiveStatements = baseActiveStatements;
    private readonly AsyncLazy<EditAndContinueCapabilities> _capabilities = capabilities;
 
    public async ValueTask<ImmutableArray<DocumentAnalysisResults>> GetDocumentAnalysesAsync(
        CommittedSolution oldSolution,
        IReadOnlyList<(Document? oldDocument, Document newDocument)> documents,
        ActiveStatementSpanProvider activeStatementSpanProvider,
        CancellationToken cancellationToken)
    {
        try
        {
            if (documents.IsEmpty())
            {
                return [];
            }
 
            var tasks = documents.Select(document => Task.Run(() => GetDocumentAnalysisAsync(oldSolution, document.oldDocument, document.newDocument, activeStatementSpanProvider, cancellationToken).AsTask(), cancellationToken));
            var allResults = await Task.WhenAll(tasks).ConfigureAwait(false);
 
            return [.. allResults];
        }
        catch (Exception e) when (FatalError.ReportAndPropagateUnlessCanceled(e, cancellationToken))
        {
            throw ExceptionUtilities.Unreachable();
        }
    }
 
    /// <summary>
    /// Returns a document analysis or kicks off a new one if one is not available for the specified document snapshot.
    /// </summary>
    /// <param name="oldSolution">Committed solution.</param>
    /// <param name="newDocument">Document snapshot to analyze.</param>
    /// <param name="activeStatementSpanProvider">Provider of active statement spans tracked by the editor for the solution snapshot of the <paramref name="newDocument"/>.</param>
    public async ValueTask<DocumentAnalysisResults> GetDocumentAnalysisAsync(
        CommittedSolution oldSolution,
        Document? oldDocument,
        Document newDocument,
        ActiveStatementSpanProvider activeStatementSpanProvider,
        CancellationToken cancellationToken)
    {
        try
        {
            var unmappedActiveStatementSpans = await GetLatestUnmappedActiveStatementSpansAsync(oldDocument, newDocument, activeStatementSpanProvider, cancellationToken).ConfigureAwait(false);
 
            // The base project may have been updated as documents were brought up-to-date in the committed solution.
            // Get the latest available snapshot of the base project from the committed solution and use it for analyses of all documents,
            // so that we use a single compilation for the base project (for efficiency).
            // Note that some other request might be updating documents in the committed solution that were not changed (not in changedOrAddedDocuments)
            // but are not up-to-date. These documents do not have impact on the analysis unless we read semantic information
            // from the project compilation. When reading such information we need to be aware of its potential incompleteness
            // and consult the compiler output binary (see https://github.com/dotnet/roslyn/issues/51261).
            var oldProject = oldDocument?.Project ?? oldSolution.GetRequiredProject(newDocument.Project.Id);
 
            AsyncLazy<DocumentAnalysisResults> lazyResults;
 
            lock (_guard)
            {
                lazyResults = GetDocumentAnalysisNoLock(oldProject, newDocument, unmappedActiveStatementSpans);
            }
 
            return await lazyResults.GetValueAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (Exception e) when (FatalError.ReportAndPropagateUnlessCanceled(e, cancellationToken))
        {
            throw ExceptionUtilities.Unreachable();
        }
    }
 
    /// <summary>
    /// Calculates unmapped active statement spans in the <paramref name="newDocument"/> from spans provided by <paramref name="newActiveStatementSpanProvider"/>.
    /// </summary>
    private async Task<ImmutableArray<ActiveStatementLineSpan>> GetLatestUnmappedActiveStatementSpansAsync(Document? oldDocument, Document newDocument, ActiveStatementSpanProvider newActiveStatementSpanProvider, CancellationToken cancellationToken)
    {
        if (oldDocument == null)
        {
            // document has been deleted or is out-of-sync
            return [];
        }
 
        if (newDocument.FilePath == null)
        {
            // document has been added, or doesn't have a file path - we do not have tracking spans for it
            return [];
        }
 
        var newTree = await newDocument.GetRequiredSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
        var newLineMappings = newTree.GetLineMappings(cancellationToken);
 
        // No #line directives -- retrieve the current location of tracking spans directly for this document:
        if (!newLineMappings.Any())
        {
            var newMappedDocumentSpans = await newActiveStatementSpanProvider(newDocument.Id, newDocument.FilePath, cancellationToken).ConfigureAwait(false);
            return newMappedDocumentSpans.SelectAsArray(static s => new ActiveStatementLineSpan(s.Id, s.LineSpan));
        }
 
        // The document has #line directives. In order to determine all active statement spans in the document
        // we need to find all documents that #line directives in this document map to.
        // We retrieve the tracking spans for all such documents and then map them back to this document.
 
        using var _1 = PooledDictionary<string, ImmutableArray<ActiveStatementSpan>>.GetInstance(out var mappedSpansByDocumentPath);
        using var _2 = ArrayBuilder<ActiveStatementLineSpan>.GetInstance(out var activeStatementSpansBuilder);
 
        var baseActiveStatements = await _baseActiveStatements.GetValueAsync(cancellationToken).ConfigureAwait(false);
        var analyzer = newDocument.Project.Services.GetRequiredService<IEditAndContinueAnalyzer>();
        var oldActiveStatements = await baseActiveStatements.GetOldActiveStatementsAsync(analyzer, oldDocument, cancellationToken).ConfigureAwait(false);
 
        foreach (var oldActiveStatement in oldActiveStatements)
        {
            var mappedFilePath = oldActiveStatement.Statement.FileSpan.Path;
            if (!mappedSpansByDocumentPath.TryGetValue(mappedFilePath, out var newMappedDocumentSpans))
            {
                newMappedDocumentSpans = await newActiveStatementSpanProvider((newDocument.FilePath == mappedFilePath) ? newDocument.Id : null, mappedFilePath, cancellationToken).ConfigureAwait(false);
                mappedSpansByDocumentPath.Add(mappedFilePath, newMappedDocumentSpans);
            }
 
            // Spans not tracked in the document (e.g. the document has been closed):
            if (newMappedDocumentSpans.IsEmpty)
            {
                continue;
            }
 
            // all baseline spans are being tracked in their corresponding mapped documents (if a span is deleted it's still tracked as empty):
            var newMappedDocumentActiveSpan = newMappedDocumentSpans.Single(static (s, id) => s.Id == id, oldActiveStatement.Statement.Id);
            Debug.Assert(newMappedDocumentActiveSpan.UnmappedDocumentId == null || newMappedDocumentActiveSpan.UnmappedDocumentId == newDocument.Id);
 
            // TODO: optimize
            var newLineMappingContainingActiveSpan = newLineMappings.FirstOrDefault(mapping => mapping.MappedSpan.Span.Contains(newMappedDocumentActiveSpan.LineSpan));
 
            var unmappedSpan = newLineMappingContainingActiveSpan.MappedSpan.IsValid ? newLineMappingContainingActiveSpan.Span : default;
            activeStatementSpansBuilder.Add(new ActiveStatementLineSpan(newMappedDocumentActiveSpan.Id, unmappedSpan));
        }
 
        return activeStatementSpansBuilder.ToImmutableAndClear();
    }
 
    private AsyncLazy<DocumentAnalysisResults> GetDocumentAnalysisNoLock(Project baseProject, Document document, ImmutableArray<ActiveStatementLineSpan> activeStatementSpans)
    {
        // Do not reuse an analysis of the document unless its snasphot is exactly the same as was used to calculate the results.
        // Note that comparing document snapshots in effect compares the entire solution snapshots (when another document is changed a new solution snapshot is created
        // that creates new document snapshots for all queried documents).
        // Also check the base project snapshot since the analysis uses semantic information from the base project as well.
        // 
        // It would be possible to reuse analysis results of documents whose content does not change in between two solution snapshots.
        // However, we'd need rather sophisticated caching logic. The semantic analysis gathers information from other documents when
        // calculating results for a specific document. In some cases it's easy to record the set of documents the analysis depends on.
        // For example, when analyzing a partial class we can record all documents its declaration spans. However, in other cases the analysis
        // checks for absence of a top-level type symbol. Adding a symbol to any document thus invalidates such analysis. It'd be possible
        // to keep track of which type symbols an analysis is conditional upon, if it was worth the extra complexity.
        if (_analyses.TryGetValue(document.Id, out var analysis) &&
            analysis.baseProject == baseProject &&
            analysis.document == document &&
            analysis.activeStatementSpans.SequenceEqual(activeStatementSpans))
        {
            return analysis.results;
        }
 
        var lazyResults = AsyncLazy.Create(
            static async (arg, cancellationToken) =>
            {
                try
                {
                    var analyzer = arg.document.Project.Services.GetRequiredService<IEditAndContinueAnalyzer>();
                    return await analyzer.AnalyzeDocumentAsync(arg.baseProject, arg.self._baseActiveStatements, arg.document, arg.activeStatementSpans, arg.self._capabilities, cancellationToken).ConfigureAwait(false);
                }
                catch (Exception e) when (FatalError.ReportAndPropagateUnlessCanceled(e, cancellationToken))
                {
                    throw ExceptionUtilities.Unreachable();
                }
            },
            arg: (self: this, document, baseProject, activeStatementSpans));
 
        // Previous results for this document id are discarded as they are no longer relevant.
        // The only relevant analysis is for the latest base and document snapshots.
        // Note that the base snapshot may evolve if documents are dicovered that were previously
        // out-of-sync with the compiled outputs and are now up-to-date.
        _analyses[document.Id] = (lazyResults, baseProject, document, activeStatementSpans);
 
        return lazyResults;
    }
}