diff --git a/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs b/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs index 41e00efaeae52..2d1b001f6f095 100644 --- a/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs +++ b/src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticsPullCache.cs @@ -24,7 +24,7 @@ internal record struct DiagnosticsRequestState(Project Project, int GlobalStateV /// and works well for us in the normal case. The latter still allows us to reuse diagnostics when changes happen that /// update the version stamp but not the content (for example, forking LSP text). /// - private sealed class DiagnosticsPullCache(string uniqueKey) : VersionedPullCache<(int globalStateVersion, VersionStamp? dependentVersion), (int globalStateVersion, Checksum dependentChecksum), DiagnosticsRequestState, DiagnosticData>(uniqueKey) + private sealed class DiagnosticsPullCache(string uniqueKey) : VersionedPullCache<(int globalStateVersion, VersionStamp? dependentVersion), (int globalStateVersion, Checksum dependentChecksum), DiagnosticsRequestState, ImmutableArray>(uniqueKey) { public override async Task<(int globalStateVersion, VersionStamp? dependentVersion)> ComputeCheapVersionAsync(DiagnosticsRequestState state, CancellationToken cancellationToken) { diff --git a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs index 4c7abb5d4ba86..6def0f52d7f81 100644 --- a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs +++ b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.CacheItem.cs @@ -52,7 +52,7 @@ private sealed class CacheItem(string uniqueKey) /// /// Returns if the previousPullResult can be re-used, otherwise returns a new resultId and the new data associated with it. /// - public async Task<(string, ImmutableArray)?> UpdateCacheItemAsync( + public async Task<(string, TComputedData)?> UpdateCacheItemAsync( VersionedPullCache cache, PreviousPullResult? previousPullResult, bool isFullyLoaded, diff --git a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs index 740ee258b2a3c..aacaeca1cdc14 100644 --- a/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs +++ b/src/LanguageServer/Protocol/Handler/PullHandlers/VersionedPullCache.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Host; @@ -19,7 +18,6 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; /// with different computation costs to determine if the previous cached data is still valid. /// internal abstract partial class VersionedPullCache(string uniqueKey) - where TComputedData : notnull { /// /// Map of workspace and diagnostic source to the data used to make the last pull report. @@ -59,9 +57,9 @@ internal abstract partial class VersionedPullCache. /// - public abstract Task> ComputeDataAsync(TState state, CancellationToken cancellationToken); + public abstract Task ComputeDataAsync(TState state, CancellationToken cancellationToken); - public abstract Checksum ComputeChecksum(ImmutableArray data); + public abstract Checksum ComputeChecksum(TComputedData data); /// /// If results have changed since the last request this calculates and returns a new @@ -70,7 +68,7 @@ internal abstract partial class VersionedPullCachea map of roslyn document or project id to the previous result the client sent us for that doc. /// the id of the project or document that we are checking to see if it has changed. /// Null when results are unchanged, otherwise returns a non-null new resultId. - public async Task<(string ResultId, ImmutableArray Data)?> GetOrComputeNewDataAsync( + public async Task<(string ResultId, TComputedData Data)?> GetOrComputeNewDataAsync( Dictionary idToClientLastResult, ProjectOrDocumentId projectOrDocumentId, Project project, diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs new file mode 100644 index 0000000000000..4bbdc4e175db9 --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentCache.cs @@ -0,0 +1,61 @@ +// 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.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Text; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; + +internal record struct SourceGeneratedDocumentGetTextState(Document Document); + +internal sealed class SourceGeneratedDocumentCache(string uniqueKey) : VersionedPullCache<(SourceGeneratorExecutionVersion, VersionStamp), object?, SourceGeneratedDocumentGetTextState, SourceText?>(uniqueKey), ILspService +{ + public override async Task<(SourceGeneratorExecutionVersion, VersionStamp)> ComputeCheapVersionAsync(SourceGeneratedDocumentGetTextState state, CancellationToken cancellationToken) + { + // The execution version and the dependent version must be considered as one version cached together - + // it is not correct to say that if the execution version is the same then we can re-use results (as in automatic mode the execution version never changes). + var executionVersion = state.Document.Project.Solution.GetSourceGeneratorExecutionVersion(state.Document.Project.Id); + var dependentVersion = await state.Document.Project.GetDependentVersionAsync(cancellationToken).ConfigureAwait(false); + return (executionVersion, dependentVersion); + } + + public override Task ComputeExpensiveVersionAsync(SourceGeneratedDocumentGetTextState state, CancellationToken cancellationToken) + { + return SpecializedTasks.Null(); + } + + public override Checksum ComputeChecksum(SourceText? data) + { + return data is null ? Checksum.Null : Checksum.From(data.GetChecksum()); + } + + public override async Task ComputeDataAsync(SourceGeneratedDocumentGetTextState state, CancellationToken cancellationToken) + { + // When a user has a open source-generated file, we ensure that the contents in the LSP snapshot match the contents that we + // get through didOpen/didChanges, like any other file. That way operations in LSP file are in sync with the + // contents the user has. However in this case, we don't want to look at that frozen text, but look at what the + // generator would generate if we ran it again. Otherwise, we'll get "stuck" and never update the file with something new. + // This can return null when the source generated file has been removed (but the queue itself is using the frozen non-null document). + var unfrozenDocument = await state.Document.Project.Solution.WithoutFrozenSourceGeneratedDocuments().GetDocumentAsync(state.Document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); + return unfrozenDocument == null + ? null + : await unfrozenDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); + } +} + +[ExportCSharpVisualBasicLspServiceFactory(typeof(SourceGeneratedDocumentCache)), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class SourceGeneratedDocumentCacheFactory() : ILspServiceFactory +{ + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + { + return new SourceGeneratedDocumentCache(this.GetType().Name); + } +} diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs index 31402aaa61f25..6354c26e9b464 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs @@ -3,14 +3,17 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Composition; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics; +using Microsoft.CodeAnalysis.PooledObjects; using Roslyn.Utilities; using LSP = Roslyn.LanguageServer.Protocol; -namespace Microsoft.CodeAnalysis.LanguageServer.Handler; +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; [ExportCSharpVisualBasicStatelessLspService(typeof(SourceGeneratedDocumentGetTextHandler)), Shared] [Method(MethodName)] @@ -29,18 +32,46 @@ public async Task HandleRequestAsync(SourceGenerato { var document = context.Document; + if (document is null) + { + // The source generated file being asked about is not present. + // This is a rare case the request queue always gives us a frozen, non-null document for any opened sg document, + // even if the generator itself was removed and the document no longer exists in the host solution. + // + // We can only get a null document here if the sg document has not been opened and + // the source generated document does not exist in the workspace. + // + // Return a value indicating that the document is removed. + return new SourceGeneratedDocumentText(ResultId: null, Text: null); + } + // Nothing here strictly prevents this from working on any other document, but we'll assert we got a source-generated file, since // it wouldn't really make sense for the server to be asked for the contents of a regular file. Since this endpoint is intended for // source-generated files only, this would indicate that something else has gone wrong. Contract.ThrowIfFalse(document is SourceGeneratedDocument); - // When a user has a open source-generated file, we ensure that the contents in the LSP snapshot match the contents that we - // get through didOpen/didChanges, like any other file. That way operations in LSP file are in sync with the - // contents the user has. However in this case, we don't want to look at that frozen text, but look at what the - // generator would generate if we ran it again. Otherwise, we'll get "stuck" and never update the file with something new. - document = await document.Project.Solution.WithoutFrozenSourceGeneratedDocuments().GetDocumentAsync(document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); + var cache = context.GetRequiredLspService(); + var projectOrDocument = new ProjectOrDocumentId(document.Id); + + using var _ = PooledDictionary.GetInstance(out var previousPullResults); + if (request.ResultId is not null) + { + previousPullResults.Add(projectOrDocument, new PreviousPullResult(request.ResultId, request.TextDocument)); + } + + var newResult = await cache.GetOrComputeNewDataAsync(previousPullResults, projectOrDocument, document.Project, new SourceGeneratedDocumentGetTextState(document), cancellationToken).ConfigureAwait(false); - var text = document != null ? await document.GetTextAsync(cancellationToken).ConfigureAwait(false) : null; - return new SourceGeneratedDocumentText(text?.ToString()); + if (newResult is null) + { + Contract.ThrowIfNull(request.ResultId, "Attempted to reuse cache entry but given no resultId"); + // The generated document is the same, we can return the same resultId. + return new SourceGeneratedDocumentText(request.ResultId, Text: null); + } + else + { + // We may get no text back if the unfrozen source generated file no longer exists. + var data = newResult.Value.Data?.ToString(); + return new SourceGeneratedDocumentText(newResult.Value.ResultId, data); + } } } diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs index 17c1105a0870e..10fc9991c6c93 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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. @@ -6,4 +6,16 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; -internal sealed record SourceGeneratedDocumentText([property: JsonPropertyName("text")] string? Text); \ No newline at end of file +/// +/// Source generated file text result. The client uses the resultId to inform what the text value is. +/// +/// An unchanged result has a non-null resultId (same as client request resultId) + null text. +/// +/// A changed result has a new non-null resultId + possibly null text (if the sg document no longer exists). +/// +/// In rare circumstances it is possible to get a null resultId + null text - this happens when +/// the source generated document is not open AND the source generated document no longer exists +/// +internal sealed record SourceGeneratedDocumentText( + [property: JsonPropertyName("resultId")] string? ResultId, + [property: JsonPropertyName("text")] string? Text); diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs index f930f1cc438c0..9537b6d607d2b 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs @@ -1,10 +1,12 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.Text.Json.Serialization; using Roslyn.LanguageServer.Protocol; -namespace Microsoft.CodeAnalysis.LanguageServer.Handler; +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; -internal sealed record SourceGeneratorGetTextParams([property: JsonPropertyName("textDocument")] TextDocumentIdentifier TextDocument) : ITextDocumentParams; \ No newline at end of file +internal sealed record SourceGeneratorGetTextParams( + [property: JsonPropertyName("textDocument")] TextDocumentIdentifier TextDocument, + [property: JsonPropertyName("resultId")] string? ResultId) : ITextDocumentParams; diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs new file mode 100644 index 0000000000000..e88c75b25ae9c --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueue.cs @@ -0,0 +1,147 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Roslyn.LanguageServer.Protocol; +using Roslyn.Utilities; +using StreamJsonRpc; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; + +internal sealed class SourceGeneratorRefreshQueue : + IOnInitialized, + ILspService, + IDisposable +{ + private const string RefreshSourceGeneratedDocumentName = "workspace/refreshSourceGeneratedDocument"; + + private readonly IAsynchronousOperationListener _asyncListener; + private readonly CancellationTokenSource _disposalTokenSource = new(); + private readonly LspWorkspaceRegistrationService _lspWorkspaceRegistrationService; + private readonly LspWorkspaceManager _lspWorkspaceManager; + private readonly IClientLanguageServerManager _notificationManager; + private readonly AsyncBatchingWorkQueue _refreshQueue; + + public SourceGeneratorRefreshQueue( + IAsynchronousOperationListenerProvider asynchronousOperationListenerProvider, + LspWorkspaceRegistrationService lspWorkspaceRegistrationService, + LspWorkspaceManager lspWorkspaceManager, + IClientLanguageServerManager notificationManager) + { + _lspWorkspaceRegistrationService = lspWorkspaceRegistrationService; + _lspWorkspaceManager = lspWorkspaceManager; + _notificationManager = notificationManager; + _asyncListener = asynchronousOperationListenerProvider.GetListener(FeatureAttribute.SourceGenerators); + + // Batch up workspace notifications so that we only send a notification to refresh source generated files + // every 2 seconds - long enough to avoid spamming the client with notifications, but short enough to refresh + // the source generated files relatively frequently. + _refreshQueue = _refreshQueue = new AsyncBatchingWorkQueue( + delay: DelayTimeSpan.Idle, + processBatchAsync: RefreshSourceGeneratedDocumentsAsync, + asyncListener: _asyncListener, + _disposalTokenSource.Token); + } + + public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken) + { + if (clientCapabilities.HasVisualStudioLspCapability()) + { + // VS source generated document content is not provided by LSP. + return Task.CompletedTask; + } + + // After we have initialized we can start listening for workspace changes. + _lspWorkspaceRegistrationService.LspSolutionChanged += OnLspSolutionChanged; + return Task.CompletedTask; + } + + private void OnLspSolutionChanged(object? sender, WorkspaceChangeEventArgs e) + { + var asyncToken = _asyncListener.BeginAsyncOperation($"{nameof(SourceGeneratorRefreshQueue)}.{nameof(OnLspSolutionChanged)}"); + _ = OnLspSolutionChangedAsync(e) + .CompletesAsyncOperation(asyncToken) + .ReportNonFatalErrorUnlessCancelledAsync(_disposalTokenSource.Token); + } + + private async Task OnLspSolutionChangedAsync(WorkspaceChangeEventArgs e) + { + var projectId = e.ProjectId ?? e.DocumentId?.ProjectId; + if (projectId is not null) + { + // We have a specific changed project - do some additional checks to see if + // source generators possibly changed. Note that this overreports actual + // changes to the source generated text; we rely on resultIds in the text retrieval to avoid unnecessary serialization. + + // Trivial check. see if the SG version of these projects changed. If so, we definitely want to update + // this generated file. + if (e.OldSolution.GetSourceGeneratorExecutionVersion(projectId) != + e.NewSolution.GetSourceGeneratorExecutionVersion(projectId)) + { + _refreshQueue.AddWork(); + return; + } + + var oldProject = e.OldSolution.GetProject(projectId); + var newProject = e.NewSolution.GetProject(projectId); + + if (oldProject != null && newProject != null) + { + await CheckDependentVersionsAsync(oldProject, newProject).ConfigureAwait(false); + } + } + else + { + // We don't have a specific project change - if this is a solution change we need to queue a refresh anyway. + if (e.Kind is WorkspaceChangeKind.SolutionChanged or WorkspaceChangeKind.SolutionAdded or WorkspaceChangeKind.SolutionRemoved or WorkspaceChangeKind.SolutionReloaded or WorkspaceChangeKind.SolutionCleared) + { + _refreshQueue.AddWork(); + } + } + + async Task CheckDependentVersionsAsync(Project oldProject, Project newProject) + { + if (await oldProject.GetDependentVersionAsync(_disposalTokenSource.Token).ConfigureAwait(false) != + await newProject.GetDependentVersionAsync(_disposalTokenSource.Token).ConfigureAwait(false)) + { + _refreshQueue.AddWork(); + } + } + } + + private ValueTask RefreshSourceGeneratedDocumentsAsync( + CancellationToken cancellationToken) + { + var hasOpenSourceGeneratedDocuments = _lspWorkspaceManager.GetTrackedLspText().Keys.Any(uri => uri.Scheme == SourceGeneratedDocumentUri.Scheme); + if (!hasOpenSourceGeneratedDocuments) + { + // There are no opened source generated documents - we don't need to bother asking the client to refresh anything. + return ValueTaskFactory.CompletedTask; + } + + try + { + return _notificationManager.SendNotificationAsync(RefreshSourceGeneratedDocumentName, cancellationToken); + } + catch (Exception ex) when (ex is ObjectDisposedException or ConnectionLostException) + { + // It is entirely possible that we're shutting down and the connection is lost while we're trying to send a notification + // as this runs outside of the guaranteed ordering in the queue. We can safely ignore this exception. + } + + return ValueTaskFactory.CompletedTask; + } + + public void Dispose() + { + _lspWorkspaceRegistrationService.LspSolutionChanged -= OnLspSolutionChanged; + _disposalTokenSource.Cancel(); + _disposalTokenSource.Dispose(); + } +} diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueueFactory.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueueFactory.cs new file mode 100644 index 0000000000000..4b8531d0bf5ff --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorRefreshQueueFactory.cs @@ -0,0 +1,25 @@ +// 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.Composition; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Shared.TestHooks; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; + +[ExportCSharpVisualBasicLspServiceFactory(typeof(SourceGeneratorRefreshQueue)), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class SourceGeneratorRefreshQueueFactory( + IAsynchronousOperationListenerProvider asynchronousOperationListenerProvider, + LspWorkspaceRegistrationService lspWorkspaceRegistrationService) : ILspServiceFactory +{ + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + { + var notificationManager = lspServices.GetRequiredService(); + var lspWorkspaceManager = lspServices.GetRequiredService(); + return new SourceGeneratorRefreshQueue(asynchronousOperationListenerProvider, lspWorkspaceRegistrationService, lspWorkspaceManager, notificationManager); + } +} \ No newline at end of file diff --git a/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs b/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs index 33df968335b8e..d997635b6cb60 100644 --- a/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs +++ b/src/LanguageServer/Protocol/Handler/SpellCheck/SpellCheckPullCache.cs @@ -16,7 +16,7 @@ internal record struct SpellCheckState(ISpellCheckSpanService Service, Document /// Simplified version of that only uses a /// single cheap key to check results against. /// -internal sealed class SpellCheckPullCache(string uniqueKey) : VersionedPullCache<(Checksum parseOptionsChecksum, Checksum textChecksum)?, object?, SpellCheckState, SpellCheckSpan>(uniqueKey) +internal sealed class SpellCheckPullCache(string uniqueKey) : VersionedPullCache<(Checksum parseOptionsChecksum, Checksum textChecksum)?, object?, SpellCheckState, ImmutableArray>(uniqueKey) { public override async Task<(Checksum parseOptionsChecksum, Checksum textChecksum)?> ComputeCheapVersionAsync(SpellCheckState state, CancellationToken cancellationToken) { diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs index 1889e398f460e..1d6ae9f07598d 100644 --- a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs @@ -2,13 +2,18 @@ // 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.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Microsoft.CodeAnalysis.LanguageServer.Handler.SourceGenerators; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Test.Utilities; +using Microsoft.CodeAnalysis.Text; using Roslyn.Test.Utilities; using Roslyn.Test.Utilities.TestGenerators; +using Roslyn.Utilities; using Xunit; using Xunit.Abstractions; using LSP = Roslyn.LanguageServer.Protocol; @@ -27,7 +32,7 @@ public async Task ReturnsTextForSourceGeneratedDocument(bool mutatingLspWorkspac var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, - new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); AssertEx.NotNull(text); Assert.Equal("// Hello, World", text.Text); @@ -43,7 +48,7 @@ public async Task OpenCloseSourceGeneratedDocument(bool mutatingLspWorkspace) var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, - new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); AssertEx.NotNull(text); Assert.Equal("// Hello, World", text.Text); @@ -68,7 +73,7 @@ public async Task OpenMultipleSourceGeneratedDocument(bool mutatingLspWorkspace) foreach (var sourceGeneratorDocumentUri in sourceGeneratorDocumentUris) { var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, - new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); AssertEx.NotNull(text?.Text); await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, text.Text); } @@ -97,6 +102,191 @@ public async Task RequestOnSourceGeneratedDocument(bool mutatingLspWorkspace) Assert.Contains("class A", hover.Contents.Fourth.Value); } + [Theory, CombinatorialData] + public async Task ReturnsGeneratedSourceForOpenDocument(bool mutatingLspWorkspace) + { + var sourceGeneratorSource = "// Hello, World"; + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, sourceGeneratorSource); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + // Open the document with different text - this will cause the queue to generate frozen sg documents using this value. + // However the get text handler should return the real source generator source. + await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, "LSP Open Document Text"); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal(sourceGeneratorSource, text.Text); + } + + [Theory, CombinatorialData] + public async Task TestReturnsUnchangedResult(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, "// Hello, World"); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + + // Make a second request - since nothing has changed we should get back the same resultId. + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + AssertEx.NotNull(secondRequest); + Assert.Null(secondRequest.Text); + Assert.Equal(text.ResultId, secondRequest.ResultId); + } + + [Theory, CombinatorialData] + internal async Task TestReturnsGeneratedSourceWhenDocumentChanges(bool mutatingLspWorkspace, SourceGeneratorExecutionPreference sourceGeneratorExecution) + { + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + + var configService = testLspServer.TestWorkspace.ExportProvider.GetExportedValue(); + configService.Options = new WorkspaceConfigurationOptions(SourceGeneratorExecution: sourceGeneratorExecution); + + var callCount = 0; + var generatorReference = await AddGeneratorAsync(new CallbackGenerator(() => ("hintName.cs", "// callCount: " + callCount++)), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// callCount: 0", text.Text); + + // Modify a normal document in the workspace. + // In automatic mode this should trigger generators to re-run. + // In balanced mode generators should not re-run. + await testLspServer.TestWorkspace.ChangeDocumentAsync(testLspServer.TestWorkspace.Documents.Single(d => !d.IsSourceGenerated).Id, SourceText.From("new text")); + await WaitForSourceGeneratorsAsync(testLspServer.TestWorkspace); + + // Ask for the source generated text again. + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + + if (sourceGeneratorExecution == SourceGeneratorExecutionPreference.Automatic) + { + // We should get newly generated text + AssertEx.NotNull(secondRequest); + Assert.NotEqual(text.ResultId, secondRequest.ResultId); + Assert.Equal("// callCount: 1", secondRequest.Text); + } + else + { + // We should get an unchanged result + AssertEx.NotNull(secondRequest); + Assert.Equal(text.ResultId, secondRequest.ResultId); + Assert.Null(secondRequest.Text); + } + } + + [Theory, CombinatorialData] + internal async Task TestReturnsGeneratedSourceWhenManuallyRefreshed(bool mutatingLspWorkspace, SourceGeneratorExecutionPreference sourceGeneratorExecution) + { + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + + var configService = testLspServer.TestWorkspace.ExportProvider.GetExportedValue(); + configService.Options = new WorkspaceConfigurationOptions(SourceGeneratorExecution: sourceGeneratorExecution); + + var callCount = 0; + var generatorReference = await AddGeneratorAsync(new CallbackGenerator(() => ("hintName.cs", "// callCount: " + callCount++)), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// callCount: 0", text.Text); + + // Updating the execution version should trigger source generators to run in both automatic and balanced mode. + testLspServer.TestWorkspace.EnqueueUpdateSourceGeneratorVersion(projectId: null, forceRegeneration: true); + await WaitForSourceGeneratorsAsync(testLspServer.TestWorkspace); + + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + AssertEx.NotNull(secondRequest); + Assert.NotEqual(text.ResultId, secondRequest.ResultId); + Assert.Equal("// callCount: 1", secondRequest.Text); + } + + [Theory, CombinatorialData] + public async Task TestReturnsNullForRemovedClosedGeneratedFile(bool mutatingLspWorkspace) + { + var generatorText = "// Hello, World"; + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + var generatorReference = await AddGeneratorAsync(new SingleFileTestGenerator(generatorText), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + + // Remove the generator and verify that we get null text back. + await RemoveGeneratorAsync(generatorReference, testLspServer.TestWorkspace); + + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + + Assert.NotNull(secondRequest); + Assert.Null(secondRequest.Text); + } + + [Theory, CombinatorialData] + public async Task TestReturnsNullForRemovedOpenedGeneratedFile(bool mutatingLspWorkspace) + { + var generatorText = "// Hello, World"; + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + var generatorReference = await AddGeneratorAsync(new SingleFileTestGenerator(generatorText), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: null), CancellationToken.None); + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + + // Open the document - this will cause the queue to generate frozen sg documents based on the LSP open text + // even if the source generator is removed entirely. + await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, text.Text); + + // Remove the generator - the handler should return null text. + await RemoveGeneratorAsync(generatorReference, testLspServer.TestWorkspace); + + var secondRequest = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }, ResultId: text.ResultId), CancellationToken.None); + + Assert.NotNull(secondRequest); + Assert.Null(secondRequest.Text); + } + + private static async Task WaitForSourceGeneratorsAsync(EditorTestWorkspace workspace) + { + var operations = workspace.ExportProvider.GetExportedValue(); + await operations.WaitAllAsync(workspace, [FeatureAttribute.Workspace, FeatureAttribute.SourceGenerators]); + } + private async Task CreateTestLspServerWithGeneratorAsync(bool mutatingLspWorkspace, string generatedDocumentText) { var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace);