File: Workspaces\LspWorkspaceManager.cs
Web Access
Project: src\src\LanguageServer\Protocol\Microsoft.CodeAnalysis.LanguageServer.Protocol.csproj (Microsoft.CodeAnalysis.LanguageServer.Protocol)
// 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.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
using Microsoft.CodeAnalysis.LanguageServer.Handler.DocumentChanges;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Collections;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.LanguageServer;
 
/// <summary>
/// Manages the registered workspaces and corresponding LSP solutions for an LSP server.
/// This type is tied to a particular server.
/// </summary>
/// <remarks>
/// This type provides an LSP view of the registered workspace solutions so that all LSP requests operate
/// on the state of the world that matches the LSP requests we've received.  
/// 
/// This is done by storing the LSP text as provided by client didOpen/didClose/didChange requests.  When asked for a document we provide either
/// <list type="bullet">
///     <item> The exact workspace solution instance if all the LSP text matches what is currently in the workspace.</item>
///     <item> A fork from the workspace current solution with the LSP text applied if the LSP text does not match.  This can happen since
///     LSP text sync is asynchronous and not guaranteed to match the text in the workspace (though the majority of the time in VS it does).</item>
/// </list>
/// 
/// Doing the forking like this has a few nice properties.
/// <list type="bullet">
///   <item>99% of the time the VS workspace matches the LSP text.  In those cases we do 0 re-parsing, share compilations, versions, checksum calcs, etc.</item>
///   <item>In the 1% of the time that we do not match, we can simply and easily compute a fork.</item>
///   <item>The code is relatively straightforward</item>
/// </list>
/// </remarks>
internal sealed class LspWorkspaceManager : IDocumentChangeTracker, ILspService
{
    private class LspUriComparer : IEqualityComparer<Uri>
    {
        public static readonly LspUriComparer Instance = new();
        public bool Equals(Uri? x, Uri? y)
        {
            // Compare the absolute URIs to handle the case where one URI is encoded and the other is not.
            // By default, Uri.Equals will not consider the encoded version of a URI equal to the unencoded version.
            //
            // The client is expected to be consistent in how it sends the URIs (either encoded or unencoded).
            // So we normally can safely store the URIs as they send us in our map and expect subsequent requests to be encoded in the same way and match.
            // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#uri
            //
            // However when we serialize URIs to the client, we serialize the AbsoluteUri property which is always % encoded (no matter the original representation).
            // For some requests, the client sends us exactly back what we sent (e.g. the data in a codelens/resolve request).
            // This means that for these requests, the URI we will get from the client is the encoded version (that we sent).
            // If the client sent us an unencoded URI originally, Uri.Equals will not consider it equal to the encoded version and we will fail to find the document
            //
            // So in order to resolve the encoded URI to the correct text, we can compare the AbsoluteUri properties (which are always encoded).
            if (x is not null && y is not null && x.IsAbsoluteUri && y.IsAbsoluteUri && x.AbsoluteUri == y.AbsoluteUri)
            {
                return true;
            }
            else
            {
                return Uri.Equals(x, y);
            }
        }
 
        public int GetHashCode(Uri obj)
        {
            if (obj.IsAbsoluteUri)
            {
                // Since the Uri type does not consider an encoded Uri equal to an unencoded Uri, we need to handle this ourselves.
                // The AbsoluteUri property is always encoded, so we can use this to compare the URIs (see Equals above).
                //
                // However, depending on the kind of URI, case sensitivity in AbsoluteUri should be ignored.
                // Uri.GetHashCode normally handles this internally, but the parameters it uses to determine which comparison to use are not exposed.
                //
                // Instead, we will always create the hash code ignoring case, and will rely on the Equals implementation
                // to handle collisions (between two Uris with different casing).  This should be very rare in practice.
                // Collisions can happen for non UNC URIs (e.g. `git:/blah` vs `git:/Blah`).
                return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.AbsoluteUri);
            }
            else
            {
                return obj.GetHashCode();
            }
        }
    }
 
    /// <summary>
    /// A cache from workspace to the last solution we returned for LSP.
    /// <para/> The forkedFromVersion is not null when the solution was created from a fork of the workspace with LSP
    /// text applied on top. It is null when LSP reuses the workspace solution (the LSP text matches the contents of the
    /// workspace).
    /// <para/> Access to this is guaranteed to be serial by the <see cref="RequestExecutionQueue{RequestContextType}"/>
    /// </summary>
    private readonly Dictionary<Workspace, (int? forkedFromVersion, Solution solution)> _cachedLspSolutions = [];
 
    /// <summary>
    /// Stores the current source text for each URI that is being tracked by LSP. Each time an LSP text sync
    /// notification comes in, this source text is updated to match. Used as the backing implementation for the <see
    /// cref="IDocumentChangeTracker"/>.
    /// <para/> Note that the text here is tracked regardless of whether or not we found a matching roslyn document for
    /// the URI.
    /// <para/> Access to this is guaranteed to be serial by the <see cref="RequestExecutionQueue{RequestContextType}"/>
    /// </summary>
    private ImmutableDictionary<Uri, (SourceText Text, string LanguageId)> _trackedDocuments = ImmutableDictionary<Uri, (SourceText, string)>.Empty.WithComparers(LspUriComparer.Instance);
 
    private readonly ILspLogger _logger;
    private readonly LspMiscellaneousFilesWorkspace? _lspMiscellaneousFilesWorkspace;
    private readonly LspWorkspaceRegistrationService _lspWorkspaceRegistrationService;
    private readonly ILanguageInfoProvider _languageInfoProvider;
    private readonly RequestTelemetryLogger _requestTelemetryLogger;
 
    public LspWorkspaceManager(
        ILspLogger logger,
        LspMiscellaneousFilesWorkspace? lspMiscellaneousFilesWorkspace,
        LspWorkspaceRegistrationService lspWorkspaceRegistrationService,
        ILanguageInfoProvider languageInfoProvider,
        RequestTelemetryLogger requestTelemetryLogger)
    {
        _lspMiscellaneousFilesWorkspace = lspMiscellaneousFilesWorkspace;
        _logger = logger;
        _requestTelemetryLogger = requestTelemetryLogger;
 
        _lspWorkspaceRegistrationService = lspWorkspaceRegistrationService;
        _languageInfoProvider = languageInfoProvider;
    }
 
    public EventHandler<EventArgs>? LspTextChanged;
 
    #region Implementation of IDocumentChangeTracker
 
    private static async ValueTask ApplyChangeToMutatingWorkspaceAsync(Workspace workspace, Uri uri, Func<ILspWorkspace, DocumentId, ValueTask> change)
    {
        if (workspace is not ILspWorkspace { SupportsMutation: true } mutatingWorkspace)
            return;
 
        foreach (var documentId in workspace.CurrentSolution.GetDocumentIds(uri))
            await change(mutatingWorkspace, documentId).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Called by the <see cref="DidOpenHandler"/> when a document is opened in LSP.
    /// 
    /// <see cref="DidOpenHandler.MutatesSolutionState"/> is true which means this runs serially in the <see cref="RequestExecutionQueue{RequestContextType}"/>
    /// </summary>
    public async ValueTask StartTrackingAsync(Uri uri, SourceText documentText, string languageId, CancellationToken cancellationToken)
    {
        // First, store the LSP view of the text as the uri is now owned by the LSP client.
        Contract.ThrowIfTrue(_trackedDocuments.ContainsKey(uri), $"didOpen received for {uri} which is already open.");
        _trackedDocuments = _trackedDocuments.Add(uri, (documentText, languageId));
 
        // If LSP changed, we need to compare against the workspace again to get the updated solution.
        _cachedLspSolutions.Clear();
 
        LspTextChanged?.Invoke(this, EventArgs.Empty);
 
        // Attempt to open the doc if we find it in a workspace.  Note: if we don't (because we've heard from lsp about
        // the doc before we've heard from the project system), that's ok.  We'll still attempt to open it later in
        // GetLspSolutionForWorkspaceAsync
        await TryOpenDocumentsInMutatingWorkspaceAsync(uri).ConfigureAwait(false);
 
        return;
 
        async ValueTask TryOpenDocumentsInMutatingWorkspaceAsync(Uri uri)
        {
            var registeredWorkspaces = _lspWorkspaceRegistrationService.GetAllRegistrations();
            foreach (var workspace in registeredWorkspaces)
            {
                await ApplyChangeToMutatingWorkspaceAsync(workspace, uri, (_, documentId) =>
                    workspace.TryOnDocumentOpenedAsync(documentId, documentText.Container, isCurrentContext: false, cancellationToken)).ConfigureAwait(false);
            }
        }
    }
 
    /// <summary>
    /// Called by the <see cref="DidCloseHandler"/> when a document is closed in LSP.
    /// 
    /// <see cref="DidCloseHandler.MutatesSolutionState"/> is true which means this runs serially in the <see cref="RequestExecutionQueue{RequestContextType}"/>
    /// </summary>
    public async ValueTask StopTrackingAsync(Uri uri, CancellationToken cancellationToken)
    {
        // First, stop tracking this URI and source text as it is no longer owned by LSP.
        Contract.ThrowIfFalse(_trackedDocuments.ContainsKey(uri), $"didClose received for {uri} which is not open.");
        _trackedDocuments = _trackedDocuments.Remove(uri);
 
        // If LSP changed, we need to compare against the workspace again to get the updated solution.
        _cachedLspSolutions.Clear();
 
        // Also remove it from our loose files or metadata workspace if it is still there.
        _lspMiscellaneousFilesWorkspace?.TryRemoveMiscellaneousDocument(uri, removeFromMetadataWorkspace: true);
 
        LspTextChanged?.Invoke(this, EventArgs.Empty);
 
        // Attempt to close the doc, if it is currently open in a workspace.
        await TryCloseDocumentsInMutatingWorkspaceAsync(uri).ConfigureAwait(false);
 
        return;
 
        async ValueTask TryCloseDocumentsInMutatingWorkspaceAsync(Uri uri)
        {
            var registeredWorkspaces = _lspWorkspaceRegistrationService.GetAllRegistrations();
            foreach (var workspace in registeredWorkspaces)
            {
                await ApplyChangeToMutatingWorkspaceAsync(workspace, uri, async (_, documentId) =>
                {
                    if (documentId.IsSourceGenerated)
                    {
                        // Source generated documents cannot go through OnDocumentOpened/Closed.
                        // There is a separate OnSourceGeneratedDocumentOpened/Closed method, but there is no need
                        // for us to call it in LSP - it deals with mapping TextBuffers to text containers.
                        return;
                    }
                    await workspace.TryOnDocumentClosedAsync(documentId, cancellationToken).ConfigureAwait(false);
                }).ConfigureAwait(false);
            }
        }
    }
 
    /// <summary>
    /// Called by the <see cref="DidChangeHandler"/> when a document's text is updated in LSP.
    /// 
    /// <see cref="DidChangeHandler.MutatesSolutionState"/> is true which means this runs serially in the <see cref="RequestExecutionQueue{RequestContextType}"/>
    /// </summary>
    public void UpdateTrackedDocument(Uri uri, SourceText newSourceText)
    {
        // Store the updated LSP view of the source text.
        Contract.ThrowIfFalse(_trackedDocuments.ContainsKey(uri), $"didChange received for {uri} which is not open.");
        var (_, language) = _trackedDocuments[uri];
        _trackedDocuments = _trackedDocuments.SetItem(uri, (newSourceText, language));
 
        // If LSP changed, we need to compare against the workspace again to get the updated solution.
        _cachedLspSolutions.Clear();
 
        LspTextChanged?.Invoke(this, EventArgs.Empty);
    }
 
    public ImmutableDictionary<Uri, (SourceText Text, string LanguageId)> GetTrackedLspText() => _trackedDocuments;
 
    #endregion
 
    #region LSP Solution Retrieval
 
    /// <summary>
    /// Returns the LSP solution associated with the workspace with workspace kind <see cref="WorkspaceKind.Host"/>.
    /// This is the solution used for LSP requests that pertain to the entire workspace, for example code search or
    /// workspace diagnostics.
    /// 
    /// This is always called serially in the <see cref="RequestExecutionQueue{RequestContextType}"/> when creating the <see cref="RequestContext"/>.
    /// </summary>
    public async Task<(Workspace?, Solution?)> GetLspSolutionInfoAsync(CancellationToken cancellationToken)
    {
        // Ensure we have the latest lsp solutions
        var updatedSolutions = await GetLspSolutionsAsync(cancellationToken).ConfigureAwait(false);
 
        var (hostWorkspace, hostWorkspaceSolution, isForked) = updatedSolutions.FirstOrDefault(lspSolution => lspSolution.Solution.WorkspaceKind is WorkspaceKind.Host);
        _requestTelemetryLogger.UpdateUsedForkedSolutionCounter(isForked);
 
        return (hostWorkspace, hostWorkspaceSolution);
    }
 
    /// <summary>
    /// Returns the LSP solution associated with the workspace with kind <see cref="WorkspaceKind.Host"/>. This is the
    /// solution used for LSP requests that pertain to the entire workspace, for example code search or workspace
    /// diagnostics.
    /// 
    /// This is always called serially in the <see cref="RequestExecutionQueue{RequestContextType}"/> when creating the <see cref="RequestContext"/>.
    /// </summary>
    public async Task<(Workspace?, Solution?, TextDocument?)> GetLspDocumentInfoAsync(TextDocumentIdentifier textDocumentIdentifier, CancellationToken cancellationToken)
    {
        // Get the LSP view of all the workspace solutions.
        var uri = textDocumentIdentifier.Uri;
        var lspSolutions = await GetLspSolutionsAsync(cancellationToken).ConfigureAwait(false);
 
        // Find the matching document from the LSP solutions.
        foreach (var (workspace, lspSolution, isForked) in lspSolutions)
        {
            var document = await lspSolution.GetTextDocumentAsync(textDocumentIdentifier, cancellationToken).ConfigureAwait(false);
            if (document != null)
            {
                // Record metadata on how we got this document.
                var workspaceKind = document.Project.Solution.WorkspaceKind;
                _requestTelemetryLogger.UpdateFindDocumentTelemetryData(success: true, workspaceKind);
                _requestTelemetryLogger.UpdateUsedForkedSolutionCounter(isForked);
                _logger.LogInformation($"{document.FilePath} found in workspace {workspaceKind}");
 
                // As we found the document in a non-misc workspace, also attempt to remove it from the misc workspace
                // if it happens to be in there as well.
                if (workspace != _lspMiscellaneousFilesWorkspace)
                {
                    // Do not attempt to remove the file from the metadata workspace (the document is still open).
                    _lspMiscellaneousFilesWorkspace?.TryRemoveMiscellaneousDocument(uri, removeFromMetadataWorkspace: false);
                }
 
                return (workspace, document.Project.Solution, document);
            }
        }
 
        // We didn't find the document in any workspace, record a telemetry notification that we did not find it.
        // Depending on the host, this can be entirely normal (e.g. opening a loose file)
        var searchedWorkspaceKinds = string.Join(";", lspSolutions.SelectAsArray(lspSolution => lspSolution.Solution.Workspace.Kind));
        _logger.LogInformation($"Could not find '{textDocumentIdentifier.Uri}'.  Searched {searchedWorkspaceKinds}");
        _requestTelemetryLogger.UpdateFindDocumentTelemetryData(success: false, workspaceKind: null);
 
        // Add the document to our loose files workspace (if we have one) if it iss open.
        if (_trackedDocuments.TryGetValue(uri, out var trackedDocument))
        {
            var miscDocument = _lspMiscellaneousFilesWorkspace?.AddMiscellaneousDocument(uri, trackedDocument.Text, trackedDocument.LanguageId, _logger);
            if (miscDocument is not null)
                return (miscDocument.Project.Solution.Workspace, miscDocument.Project.Solution, miscDocument);
        }
 
        return default;
    }
 
    /// <summary>
    /// Gets the LSP view of all the registered workspaces' current solutions.
    /// </summary>
    private async Task<ImmutableArray<(Workspace workspace, Solution Solution, bool IsForked)>> GetLspSolutionsAsync(CancellationToken cancellationToken)
    {
        // Ensure that the loose files workspace is searched last.
        var registeredWorkspaces = _lspWorkspaceRegistrationService.GetAllRegistrations();
        registeredWorkspaces = registeredWorkspaces
            .Where(workspace => workspace.Kind != WorkspaceKind.MiscellaneousFiles)
            .Concat(registeredWorkspaces.Where(workspace => workspace.Kind == WorkspaceKind.MiscellaneousFiles))
            .ToImmutableArray();
 
        var solutions = new FixedSizeArrayBuilder<(Workspace, Solution, bool)>(registeredWorkspaces.Length);
        foreach (var workspace in registeredWorkspaces)
        {
            // Retrieve the workspace's current view of the world at the time the request comes in. If this is changing
            // underneath, it is either the job of the LSP client to poll us (diagnostics) or we send refresh
            // notifications (semantic tokens) to the client letting them know that our workspace has changed and they
            // need to re-query us.
            var (lspSolution, isForked) = await GetLspSolutionForWorkspaceAsync(workspace, cancellationToken).ConfigureAwait(false);
            solutions.Add((workspace, lspSolution, isForked));
        }
 
        return solutions.MoveToImmutable();
 
        async Task<(Solution Solution, bool IsForked)> GetLspSolutionForWorkspaceAsync(Workspace workspace, CancellationToken cancellationToken)
        {
            var workspaceCurrentSolution = workspace.CurrentSolution;
 
            // At a high level these are the steps we take to compute what the desired LSP solution should be.
            //
            //  1. First we want to check if our workspace current solution is the same as the last workspace current
            //     solution that we verified matches the LSP text. If so, we can skip comparing the LSP text against the
            //     workspace text and just return the cached one since absolutely nothing has changed. Importantly, we
            //     do not return a cached forked solution - we do not want to re-use a forked solution if the LSP text
            //     has changed and now matches the workspace.
            //
            //  2. Next, ensure that any changes we've collected are pushed through to the underlying workspace *if* 
            //     it's a mutating workspace.  This will bring that workspace into sync with all that we've heard from lsp.
            //
            //  3. If the cached solution isn't a match, we compare the LSP text to the workspace's text and return the
            //     workspace text if all LSP text matches. While this does compute checksums, generally speaking that's
            //     a reasonable price to pay.  For example, we always do this in VS anyways to make OOP calls, and it is
            //     not a burden there.
            //
            //  4. Third, we check to see if we have cached a forked LSP solution for the current set of LSP texts
            //     against the current workspace version. If so, we can just reuse that instead of re-forking and
            //     blowing away the trees / source generated docs / etc. that we created for the fork.
            //
            //  5. We have nothing cached for this combination of LSP texts and workspace version.  We have exhausted
            //     our options and must create an LSP fork from the current workspace solution with the current LSP
            //     text.
            //
            // We propagate the IsForked value back up so that we only report telemetry on forking if the forked
            // solution is actually requested.
 
            // Step 1: Check if nothing has changed and we already verified that the workspace text matches our LSP text.
            if (_cachedLspSolutions.TryGetValue(workspace, out var cachedSolution) && cachedSolution.solution == workspaceCurrentSolution)
                return (workspaceCurrentSolution, IsForked: false);
 
            // Step 2: Push through any changes to the underlying workspace if it's a mutating workspace.
            await TryOpenAndEditDocumentsInMutatingWorkspaceAsync(workspace).ConfigureAwait(false);
 
            // Because the workspace may have been mutated, go back and retrieve its current snapshot so we're operating
            // against that view.
            workspaceCurrentSolution = workspace.CurrentSolution;
 
            // Step 3: Check to see if the LSP text matches the workspace text.
 
            var documentsInWorkspace = GetDocumentsForUris(_trackedDocuments.Keys.ToImmutableArray(), workspaceCurrentSolution);
            var sourceGeneratedDocuments =
                _trackedDocuments.Keys.Where(static uri => uri.Scheme == SourceGeneratedDocumentUri.Scheme)
                    .Select(uri => (identity: SourceGeneratedDocumentUri.DeserializeIdentity(workspaceCurrentSolution, uri), _trackedDocuments[uri].Text))
                    .Where(tuple => tuple.identity.HasValue)
                    .SelectAsArray(tuple => (tuple.identity!.Value, DateTime.Now, tuple.Text));
 
            // First we check if normal document text matches the workspace solution.
            // This does not look at source generated documents.
            var doesAllTextMatch = await DoesAllTextMatchWorkspaceSolutionAsync(documentsInWorkspace, cancellationToken).ConfigureAwait(false);
 
            // Then we check if source generated document text matches the workspace solution.
            // This is intentionally done differently from normal documents because the normal method will cause
            // source generators to run which we do not want to do in queue dispatch.
            var doesAllSourceGeneratedTextMatch = DoesAllSourceGeneratedTextMatchWorkspaceSolution(sourceGeneratedDocuments, workspaceCurrentSolution);
            if (doesAllTextMatch && doesAllSourceGeneratedTextMatch)
            {
                // Remember that the current LSP text matches the text in this workspace solution.
                _cachedLspSolutions[workspace] = (forkedFromVersion: null, workspaceCurrentSolution);
                return (workspaceCurrentSolution, IsForked: false);
            }
 
            // Step 4: See if we can reuse a previously forked solution.
            if (cachedSolution != default && cachedSolution.forkedFromVersion == workspaceCurrentSolution.WorkspaceVersion)
                return (cachedSolution.solution, IsForked: true);
 
            // Step 5: Fork a new solution from the workspace with the LSP text applied.
            var lspSolution = workspaceCurrentSolution;
            // If the workspace text matched we can leave the normal documents as-is
            if (!doesAllTextMatch)
            {
                foreach (var (uri, workspaceDocuments) in documentsInWorkspace)
                    lspSolution = lspSolution.WithDocumentText(workspaceDocuments.Select(d => d.Id), _trackedDocuments[uri].Text);
            }
 
            // If the source generated documents matched we can leave the source generated documents as-is
            if (!doesAllSourceGeneratedTextMatch)
            {
                lspSolution = lspSolution.WithFrozenSourceGeneratedDocuments(sourceGeneratedDocuments);
            }
 
            // Remember this forked solution and the workspace version it was forked from.
            _cachedLspSolutions[workspace] = (workspaceCurrentSolution.WorkspaceVersion, lspSolution);
            return (lspSolution, IsForked: true);
        }
 
        async ValueTask TryOpenAndEditDocumentsInMutatingWorkspaceAsync(Workspace workspace)
        {
            foreach (var (uri, (sourceText, _)) in _trackedDocuments)
            {
                await ApplyChangeToMutatingWorkspaceAsync(workspace, uri, async (mutatingWorkspace, documentId) =>
                {
                    if (documentId.IsSourceGenerated)
                    {
                        // Source generated documents cannot go through OnDocumentOpened/Closed.
                        // There is a separate OnSourceGeneratedDocumentOpened/Closed method, but there is no need
                        // for us to call it in LSP - it deals with mapping TextBuffers to text containers.
                        return;
                    }
                    // This may be the first time this workspace is hearing that this document is open from LSP's
                    // perspective. Attempt to open it there.
                    //
                    // TODO(cyrusn): Do we need to pass a correct value for isCurrentContext?  Or will that fall out from
                    // something else in lsp.
                    await workspace.TryOnDocumentOpenedAsync(
                        documentId, sourceText.Container, isCurrentContext: false, cancellationToken).ConfigureAwait(false);
 
                    // Note: there is a race here in that we might see/change/return here based on the
                    // relationship of 'sourceText' and 'currentSolution' while some other entity outside of the
                    // confines of lsp queue might update the workspace externally.  That's completely fine
                    // though.  The caller will always grab the 'current solution' again off of the workspace
                    // and check the checksums of all documents against the ones this workspace manager is
                    // tracking.  If there are any differences, it will fork and use that fork.
                    await mutatingWorkspace.UpdateTextIfPresentAsync(documentId, sourceText, cancellationToken).ConfigureAwait(false);
                }).ConfigureAwait(false);
            }
        }
    }
 
    /// <summary>
    /// Checks if the open source generator document contents matches the contents of the workspace solution.
    /// This looks at the source generator state explicitly to avoid actually running source generators
    /// </summary>
    private static bool DoesAllSourceGeneratedTextMatchWorkspaceSolution(
        ImmutableArray<(SourceGeneratedDocumentIdentity Identity, DateTime Generated, SourceText Text)> sourceGenereatedDocuments,
        Solution workspaceSolution)
    {
        var compilationState = workspaceSolution.CompilationState;
        foreach (var (identity, _, text) in sourceGenereatedDocuments)
        {
            var existingState = compilationState.TryGetSourceGeneratedDocumentStateForAlreadyGeneratedId(identity.DocumentId);
            if (existingState is null)
            {
                // We don't have existing state for at least one of the documents, so the text cannot match.
                return false;
            }
 
            var newState = existingState.WithText(text);
            if (newState != existingState)
            {
                return false;
            }
        }
 
        return true;
    }
 
    /// <summary>
    /// Given a set of documents from the workspace current solution, verify that the LSP text is the same as the document contents.
    /// </summary>
    private async Task<bool> DoesAllTextMatchWorkspaceSolutionAsync(ImmutableDictionary<Uri, ImmutableArray<TextDocument>> documentsInWorkspace, CancellationToken cancellationToken)
    {
        foreach (var (uriInWorkspace, documentsForUri) in documentsInWorkspace)
        {
            // We're comparing text, so we can take any of the linked documents.
            var firstDocument = documentsForUri.First();
            var isTextEquivalent = await AreChecksumsEqualAsync(firstDocument, _trackedDocuments[uriInWorkspace].Text, cancellationToken).ConfigureAwait(false);
 
            if (!isTextEquivalent)
            {
                _logger.LogWarning($"Text for {uriInWorkspace} did not match document text {firstDocument.Id} in workspace's {firstDocument.Project.Solution.WorkspaceKind} current solution");
                return false;
            }
        }
 
        return true;
    }
 
    private static async ValueTask<bool> AreChecksumsEqualAsync(TextDocument document, SourceText lspText, CancellationToken cancellationToken)
    {
        var documentText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
        if (documentText == lspText)
            return true;
 
        return lspText.GetContentHash().AsSpan().SequenceEqual(documentText.GetContentHash().AsSpan());
    }
 
    #endregion
 
    /// <summary>
    /// Returns a Roslyn language name for the given URI.
    /// </summary>
    internal bool TryGetLanguageForUri(Uri uri, [NotNullWhen(true)] out string? language)
    {
        string? languageId = null;
        if (_trackedDocuments.TryGetValue(uri, out var trackedDocument))
        {
            languageId = trackedDocument.LanguageId;
        }
 
        if (_languageInfoProvider.TryGetLanguageInformation(uri, languageId, out var languageInfo))
        {
            language = languageInfo.LanguageName;
            return true;
        }
 
        language = null;
        return false;
    }
 
    /// <summary>
    /// Using the workspace's current solutions, find the matching documents in for each URI.
    /// </summary>
    private static ImmutableDictionary<Uri, ImmutableArray<TextDocument>> GetDocumentsForUris(ImmutableArray<Uri> trackedDocuments, Solution workspaceCurrentSolution)
    {
        using var _ = PooledDictionary<Uri, ImmutableArray<TextDocument>>.GetInstance(out var documentsInSolution);
        foreach (var trackedDoc in trackedDocuments)
        {
            var documents = workspaceCurrentSolution.GetTextDocuments(trackedDoc);
            if (documents.Any())
            {
                documentsInSolution[trackedDoc] = documents;
            }
        }
 
        return documentsInSolution.ToImmutableDictionary();
    }
 
    internal TestAccessor GetTestAccessor()
            => new(this);
 
    internal readonly struct TestAccessor
    {
        private readonly LspWorkspaceManager _manager;
 
        public TestAccessor(LspWorkspaceManager manager)
            => _manager = manager;
 
        public LspMiscellaneousFilesWorkspace? GetLspMiscellaneousFilesWorkspace()
            => _manager._lspMiscellaneousFilesWorkspace;
 
        public bool IsWorkspaceRegistered(Workspace workspace)
        {
            return _manager._lspWorkspaceRegistrationService.GetAllRegistrations().Contains(workspace);
        }
    }
}