|
// 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.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 Microsoft.VisualStudio.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 recieved.
///
/// 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 class LspWorkspaceManager : IDocumentChangeTracker, ILspService
{
/// <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 = new();
/// <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> _trackedDocuments = ImmutableDictionary<Uri, SourceText>.Empty;
private readonly string _hostWorkspaceKind;
private readonly ILspLogger _logger;
private readonly LspMiscellaneousFilesWorkspace? _lspMiscellaneousFilesWorkspace;
private readonly LspWorkspaceRegistrationService _lspWorkspaceRegistrationService;
private readonly RequestTelemetryLogger _requestTelemetryLogger;
public LspWorkspaceManager(
ILspLogger logger,
LspMiscellaneousFilesWorkspace? lspMiscellaneousFilesWorkspace,
LspWorkspaceRegistrationService lspWorkspaceRegistrationService,
RequestTelemetryLogger requestTelemetryLogger)
{
_hostWorkspaceKind = lspWorkspaceRegistrationService.GetHostWorkspaceKind();
_lspMiscellaneousFilesWorkspace = lspMiscellaneousFilesWorkspace;
_logger = logger;
_requestTelemetryLogger = requestTelemetryLogger;
_lspWorkspaceRegistrationService = lspWorkspaceRegistrationService;
}
public EventHandler<EventArgs>? LspTextChanged;
#region Implementation of IDocumentChangeTracker
/// <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 void StartTracking(Uri uri, SourceText documentText)
{
// 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);
// If LSP changed, we need to compare against the workspace again to get the updated solution.
_cachedLspSolutions.Clear();
LspTextChanged?.Invoke(this, EventArgs.Empty);
}
/// <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 void StopTracking(Uri uri)
{
// 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 workspace if it is still there.
_lspMiscellaneousFilesWorkspace?.TryRemoveMiscellaneousDocument(uri);
LspTextChanged?.Invoke(this, EventArgs.Empty);
}
/// <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.");
_trackedDocuments = _trackedDocuments.SetItem(uri, newSourceText);
// 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> GetTrackedLspText() => _trackedDocuments;
#endregion
#region LSP Solution Retrieval
/// <summary>
/// Returns the LSP solution associated with the workspace with the specified <see cref="_hostWorkspaceKind"/>. 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 == _hostWorkspaceKind);
_requestTelemetryLogger.UpdateUsedForkedSolutionCounter(isForked);
return (hostWorkspace, hostWorkspaceSolution);
}
/// <summary>
/// Returns the LSP solution associated with the workspace with the specified <see cref="_hostWorkspaceKind"/>. 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?, Document?)> 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 documents = lspSolution.GetDocuments(textDocumentIdentifier.Uri);
if (documents.Any())
{
var document = documents.FindDocumentInProjectContext(textDocumentIdentifier, (sln, id) => sln.GetRequiredDocument(id));
// 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)
_lspMiscellaneousFilesWorkspace?.TryRemoveMiscellaneousDocument(uri);
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.
var searchedWorkspaceKinds = string.Join(";", lspSolutions.SelectAsArray(lspSolution => lspSolution.Solution.Workspace.Kind));
_logger.LogError($"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 trackedText))
{
var miscDocument = _lspMiscellaneousFilesWorkspace?.AddMiscellaneousDocument(uri, trackedText, _logger);
if (miscDocument is not null)
return (_lspMiscellaneousFilesWorkspace, 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();
using var _ = ArrayBuilder<(Workspace, Solution, bool)>.GetInstance(out var solutions);
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.ToImmutable();
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. 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. This check is performant as checksums will be computed
// for these documents in order to make requests OOP. So these are already computed or will be later
// in this request.
//
// 3. 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.
//
// 4. 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: Check to see if the LSP text matches the workspace text.
var documentsInWorkspace = GetDocumentsForUris(_trackedDocuments.Keys.ToImmutableArray(), workspaceCurrentSolution);
if (await DoesAllTextMatchWorkspaceSolutionAsync(documentsInWorkspace, cancellationToken).ConfigureAwait(false))
{
// Remember that the current LSP text matches the text in this workspace solution.
_cachedLspSolutions[workspace] = (forkedFromVersion: null, workspaceCurrentSolution);
return (workspaceCurrentSolution, IsForked: false);
}
// Step 3: See if we can reuse a previously forked solution.
if (cachedSolution != default && cachedSolution.forkedFromVersion == workspaceCurrentSolution.WorkspaceVersion)
{
return (cachedSolution.solution, IsForked: true);
}
// Step 4: Fork a new solution from the workspace with the LSP text applied.
var lspSolution = workspaceCurrentSolution;
foreach (var (uri, workspaceDocuments) in documentsInWorkspace)
{
lspSolution = lspSolution.WithDocumentText(workspaceDocuments.Select(d => d.Id), _trackedDocuments[uri]);
}
// Remember this forked solution and the workspace version it was forked from.
_cachedLspSolutions[workspace] = (workspaceCurrentSolution.WorkspaceVersion, lspSolution);
return (lspSolution, IsForked: 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<Document>> 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], 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 Task<bool> AreChecksumsEqualAsync(Document document, SourceText lspText, CancellationToken cancellationToken)
{
var documentText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
var documentTextChecksum = documentText.GetChecksum();
var lspTextChecksum = lspText.GetChecksum();
return lspTextChecksum.SequenceEqual(documentTextChecksum);
}
#endregion
/// <summary>
/// Using the workspace's current solutions, find the matching documents in for each URI.
/// </summary>
private static ImmutableDictionary<Uri, ImmutableArray<Document>> GetDocumentsForUris(ImmutableArray<Uri> trackedDocuments, Solution workspaceCurrentSolution)
{
using var _ = PooledDictionary<Uri, ImmutableArray<Document>>.GetInstance(out var documentsInSolution);
foreach (var trackedDoc in trackedDocuments)
{
var documents = workspaceCurrentSolution.GetDocuments(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);
}
}
}
|