Refresh diagnostics on project changes (#10964)

This notifies a client to refresh diagnostics when we experience project snapshot changes (except for document changed). This should help for cases where the project engine is updating but doesn't have all taghelper information yet.
This commit is contained in:
Andrew Hall 2024-10-04 15:28:37 -07:00 коммит произвёл GitHub
Родитель 9f19fa87df
Коммит bfda0df2b3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
4 изменённых файлов: 333 добавлений и 44 удалений

Просмотреть файл

@ -105,6 +105,7 @@ internal static class IServiceCollectionExtensions
services.AddHandlerWithCapabilities<DocumentPullDiagnosticsEndpoint>();
services.AddSingleton<RazorTranslateDiagnosticsService>();
services.AddSingleton(sp => new Lazy<RazorTranslateDiagnosticsService>(sp.GetRequiredService<RazorTranslateDiagnosticsService>));
services.AddSingleton<IRazorStartupService, WorkspaceDiagnosticsRefresher>();
}
public static void AddHoverServices(this IServiceCollection services)

Просмотреть файл

@ -0,0 +1,112 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Microsoft.VisualStudio.Threading;
namespace Microsoft.AspNetCore.Razor.LanguageServer;
internal sealed class WorkspaceDiagnosticsRefresher : IRazorStartupService, IDisposable
{
private readonly AsyncBatchingWorkQueue _queue;
private readonly IProjectSnapshotManager _projectSnapshotManager;
private readonly IClientCapabilitiesService _clientCapabilitiesService;
private readonly IClientConnection _clientConnection;
private bool? _supported;
private CancellationTokenSource _disposeTokenSource = new();
public WorkspaceDiagnosticsRefresher(
IProjectSnapshotManager projectSnapshotManager,
IClientCapabilitiesService clientCapabilitiesService,
IClientConnection clientConnection,
TimeSpan? delay = null)
{
_clientConnection = clientConnection;
_projectSnapshotManager = projectSnapshotManager;
_clientCapabilitiesService = clientCapabilitiesService;
_queue = new(
delay ?? TimeSpan.FromMilliseconds(200),
ProcessBatchAsync,
_disposeTokenSource.Token);
_projectSnapshotManager.Changed += ProjectSnapshotManager_Changed;
}
public void Dispose()
{
if (_disposeTokenSource.IsCancellationRequested)
{
return;
}
_projectSnapshotManager.Changed -= ProjectSnapshotManager_Changed;
_disposeTokenSource.Cancel();
_disposeTokenSource.Dispose();
}
private ValueTask ProcessBatchAsync(CancellationToken token)
{
_clientConnection
.SendNotificationAsync(Methods.WorkspaceDiagnosticRefreshName, token)
.Forget();
return default;
}
private void ProjectSnapshotManager_Changed(object? sender, ProjectChangeEventArgs e)
{
if (e.SolutionIsClosing)
{
return;
}
_supported ??= GetSupported();
if (_supported != true)
{
return;
}
if (e.Kind is not ProjectChangeKind.DocumentChanged)
{
_queue.AddWork();
}
}
private bool? GetSupported()
{
if (!_clientCapabilitiesService.CanGetClientCapabilities)
{
return null;
}
return _clientCapabilitiesService.ClientCapabilities.Workspace?.Diagnostics?.RefreshSupport;
}
internal TestAccessor GetTestAccessor()
=> new(this);
internal sealed class TestAccessor(WorkspaceDiagnosticsRefresher instance)
{
public Task WaitForRefreshAsync()
{
if (instance._disposeTokenSource.IsCancellationRequested)
{
return Task.CompletedTask;
}
return instance._queue.WaitUntilCurrentBatchCompletesAsync();
}
}
}

Просмотреть файл

@ -5,27 +5,24 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Microsoft.VisualStudio.Threading;
namespace Microsoft.AspNetCore.Razor.LanguageServer;
internal class WorkspaceSemanticTokensRefreshNotifier : IWorkspaceSemanticTokensRefreshNotifier, IDisposable
internal sealed class WorkspaceSemanticTokensRefreshNotifier : IWorkspaceSemanticTokensRefreshNotifier, IDisposable
{
private static readonly TimeSpan s_delay = TimeSpan.FromMilliseconds(250);
private readonly IClientCapabilitiesService _clientCapabilitiesService;
private readonly IClientConnection _clientConnection;
private readonly CancellationTokenSource _disposeTokenSource;
private readonly IDisposable _optionsChangeListener;
private readonly object _gate = new();
private bool? _supportsRefresh;
private bool _waitingToRefresh;
private Task _refreshTask = Task.CompletedTask;
private readonly AsyncBatchingWorkQueue _queue;
private bool _isColoringBackground;
private bool? _supportsRefresh;
public WorkspaceSemanticTokensRefreshNotifier(
IClientCapabilitiesService clientCapabilitiesService,
@ -37,10 +34,24 @@ internal class WorkspaceSemanticTokensRefreshNotifier : IWorkspaceSemanticTokens
_disposeTokenSource = new();
_queue = new(
TimeSpan.FromMilliseconds(250),
ProcessBatchAsync,
_disposeTokenSource.Token);
_isColoringBackground = optionsMonitor.CurrentValue.ColorBackground;
_optionsChangeListener = optionsMonitor.OnChange(HandleOptionsChange);
}
private ValueTask ProcessBatchAsync(CancellationToken token)
{
_clientConnection
.SendNotificationAsync(Methods.WorkspaceSemanticTokensRefreshName, token)
.Forget();
return default;
}
public void Dispose()
{
if (_disposeTokenSource.IsCancellationRequested)
@ -70,58 +81,35 @@ internal class WorkspaceSemanticTokensRefreshNotifier : IWorkspaceSemanticTokens
return;
}
lock (_gate)
// We could have been called before the LSP server has even been initialized
if (!_clientCapabilitiesService.CanGetClientCapabilities)
{
if (_waitingToRefresh)
{
// We're going to refresh shortly.
return;
}
// We could have been called before the LSP server has even been initialized
if (!_clientCapabilitiesService.CanGetClientCapabilities)
{
return;
}
_supportsRefresh ??= _clientCapabilitiesService.ClientCapabilities.Workspace?.SemanticTokens?.RefreshSupport ?? false;
if (_supportsRefresh is false)
{
return;
}
_refreshTask = RefreshAfterDelayAsync();
_waitingToRefresh = true;
return;
}
async Task RefreshAfterDelayAsync()
_supportsRefresh ??= _clientCapabilitiesService.ClientCapabilities.Workspace?.SemanticTokens?.RefreshSupport ?? false;
if (_supportsRefresh is false)
{
await Task.Delay(s_delay, _disposeTokenSource.Token).ConfigureAwait(false);
_clientConnection
.SendNotificationAsync(Methods.WorkspaceSemanticTokensRefreshName, _disposeTokenSource.Token)
.Forget();
_waitingToRefresh = false;
return;
}
_queue.AddWork();
}
internal TestAccessor GetTestAccessor()
=> new(this);
internal class TestAccessor(WorkspaceSemanticTokensRefreshNotifier instance)
internal sealed class TestAccessor(WorkspaceSemanticTokensRefreshNotifier instance)
{
public async Task WaitForNotificationAsync()
public Task WaitForNotificationAsync()
{
Task refreshTask;
lock (instance._gate)
if (instance._disposeTokenSource.IsCancellationRequested)
{
refreshTask = instance._refreshTask;
return Task.CompletedTask;
}
await refreshTask.ConfigureAwait(false);
return instance._queue.WaitUntilCurrentBatchCompletesAsync();
}
}
}

Просмотреть файл

@ -0,0 +1,188 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.Test.Common;
using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer;
using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Moq;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Razor.LanguageServer.Test;
public class WorkspaceDiagnosticRefreshTest(ITestOutputHelper testOutputHelper) : LanguageServerTestBase(testOutputHelper)
{
private static readonly TimeSpan s_delay = TimeSpan.FromMilliseconds(10);
[Fact]
public async Task WorkspaceRefreshSent()
{
var projectSnapshotManager = CreateProjectSnapshotManager();
var clientConnection = new StrictMock<IClientConnection>();
clientConnection
.Setup(c => c.SendNotificationAsync(Methods.WorkspaceDiagnosticRefreshName, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
.Verifiable();
using var publisher = new WorkspaceDiagnosticsRefresher(
projectSnapshotManager,
new TestClientCapabilitiesService(new()
{
Workspace = new()
{
Diagnostics = new()
{
RefreshSupport = true
}
}
}),
clientConnection.Object,
s_delay);
var testAccessor = publisher.GetTestAccessor();
await projectSnapshotManager.UpdateAsync(
static updater =>
{
updater.CreateAndAddProject("C:/path/to/project.csproj");
});
await testAccessor.WaitForRefreshAsync();
clientConnection.Verify();
}
[Fact]
public async Task WorkspaceRefreshSent_MultipleTimes()
{
var projectSnapshotManager = CreateProjectSnapshotManager();
var clientConnection = new StrictMock<IClientConnection>();
clientConnection
.Setup(c => c.SendNotificationAsync(Methods.WorkspaceDiagnosticRefreshName, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
using var publisher = new WorkspaceDiagnosticsRefresher(
projectSnapshotManager,
new TestClientCapabilitiesService(new()
{
Workspace = new()
{
Diagnostics = new()
{
RefreshSupport = true
}
}
}),
clientConnection.Object,
s_delay);
var testAccessor = publisher.GetTestAccessor();
await projectSnapshotManager.UpdateAsync(
static updater =>
{
updater.CreateAndAddProject("C:/path/to/project.csproj");
});
await testAccessor.WaitForRefreshAsync();
await projectSnapshotManager.UpdateAsync(
static updater =>
{
var project = (ProjectSnapshot)updater.GetProjects().First();
var directory = Path.GetDirectoryName(project.FilePath);
Assert.NotNull(directory);
var filePath = Path.Combine(directory, "document.razor");
updater.CreateAndAddDocument(project, filePath);
});
await testAccessor.WaitForRefreshAsync();
clientConnection.Verify(
c => c.SendNotificationAsync(Methods.WorkspaceDiagnosticRefreshName, It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
[Fact]
public async Task WorkspaceRefreshNotSent_ClientDoesNotSupport()
{
var projectSnapshotManager = CreateProjectSnapshotManager();
var clientConnection = new StrictMock<IClientConnection>();
using var publisher = new WorkspaceDiagnosticsRefresher(
projectSnapshotManager,
new TestClientCapabilitiesService(new()
{
Workspace = new()
{
Diagnostics = new()
{
RefreshSupport = false
}
}
}),
clientConnection.Object,
s_delay);
var testAccessor = publisher.GetTestAccessor();
await projectSnapshotManager.UpdateAsync(
static updater =>
{
updater.CreateAndAddProject("C:/path/to/project.csproj");
});
await testAccessor.WaitForRefreshAsync();
clientConnection
.Verify(c => c.SendNotificationAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task WorkspaceRefreshNotSent_RefresherDisposed()
{
var projectSnapshotManager = CreateProjectSnapshotManager();
var clientConnection = new StrictMock<IClientConnection>();
var publisher = new WorkspaceDiagnosticsRefresher(
projectSnapshotManager,
new TestClientCapabilitiesService(new()
{
Workspace = new()
{
Diagnostics = new()
{
RefreshSupport = false
}
}
}),
clientConnection.Object,
s_delay);
var testAccessor = publisher.GetTestAccessor();
publisher.Dispose();
await projectSnapshotManager.UpdateAsync(
static updater =>
{
updater.CreateAndAddProject("C:/path/to/project.csproj");
});
await testAccessor.WaitForRefreshAsync();
clientConnection
.Verify(c => c.SendNotificationAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
}