diff --git a/eng/targets/Services.props b/eng/targets/Services.props index fc2acc27df..0501df0e11 100644 --- a/eng/targets/Services.props +++ b/eng/targets/Services.props @@ -32,5 +32,6 @@ + diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs index be86fe0140..70d4fcd135 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs @@ -131,7 +131,6 @@ internal partial class RazorLanguageServer : SystemTextJsonLanguageServer(); services.AddSingleton(); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteDiagnosticsService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteDiagnosticsService.cs new file mode 100644 index 0000000000..65d6cc876c --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteDiagnosticsService.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using RoslynLspDiagnostic = Roslyn.LanguageServer.Protocol.Diagnostic; + +namespace Microsoft.CodeAnalysis.Razor.Remote; + +internal interface IRemoteDiagnosticsService : IRemoteJsonService +{ + ValueTask> GetDiagnosticsAsync( + JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, + JsonSerializableDocumentId documentId, + ImmutableArray csharpDiagnostics, + ImmutableArray htmlDiagnostics, + CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs index 4ede429257..2e4257cdb3 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs @@ -35,6 +35,7 @@ internal static class RazorServices (typeof(IRemoteDocumentSymbolService), null), (typeof(IRemoteRenameService), null), (typeof(IRemoteGoToImplementationService), null), + (typeof(IRemoteDiagnosticsService), null), ]; private const string ComponentName = "Razor"; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Diagnostics/RemoteDiagnosticsService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Diagnostics/RemoteDiagnosticsService.cs new file mode 100644 index 0000000000..cb95569cd2 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Diagnostics/RemoteDiagnosticsService.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; +using LspDiagnostic = Roslyn.LanguageServer.Protocol.Diagnostic; + +namespace Microsoft.CodeAnalysis.Remote.Razor; + +internal sealed class RemoteDiagnosticsService(in ServiceArgs args) : RazorDocumentServiceBase(in args), IRemoteDiagnosticsService +{ + internal sealed class Factory : FactoryBase + { + protected override IRemoteDiagnosticsService CreateService(in ServiceArgs args) + => new RemoteDiagnosticsService(in args); + } + + public ValueTask> GetDiagnosticsAsync( + JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, + JsonSerializableDocumentId documentId, + ImmutableArray csharpDiagnostics, + ImmutableArray htmlDiagnostics, + CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + documentId, + context => GetDiagnosticsAsync(context, csharpDiagnostics, htmlDiagnostics, cancellationToken), + cancellationToken); + + private async ValueTask> GetDiagnosticsAsync( + RemoteDocumentContext context, + ImmutableArray csharpDiagnostics, + ImmutableArray htmlDiagnostics, + CancellationToken cancellationToken) + { + // TODO: More work! + return htmlDiagnostics; + } +} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentPullDiagnosticsEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentPullDiagnosticsEndpoint.cs new file mode 100644 index 0000000000..37493d1873 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentPullDiagnosticsEndpoint.cs @@ -0,0 +1,205 @@ +// 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.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.AspNetCore.Razor.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using ExternalHandlers = Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; +using RoslynLspDiagnostic = Roslyn.LanguageServer.Protocol.Diagnostic; +using RoslynVSInternalDiagnosticReport = Roslyn.LanguageServer.Protocol.VSInternalDiagnosticReport; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(VSInternalMethods.DocumentPullDiagnosticName)] +[Export(typeof(IDynamicRegistrationProvider))] +[ExportCohostStatelessLspService(typeof(CohostDocumentPullDiagnosticsEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal class CohostDocumentPullDiagnosticsEndpoint( + IRemoteServiceInvoker remoteServiceInvoker, + IHtmlDocumentSynchronizer htmlDocumentSynchronizer, + LSPRequestInvoker requestInvoker, + IFilePathService filePathService, + ILoggerFactory loggerFactory) + : AbstractRazorCohostDocumentRequestHandler, IDynamicRegistrationProvider +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + private readonly IHtmlDocumentSynchronizer _htmlDocumentSynchronizer = htmlDocumentSynchronizer; + private readonly LSPRequestInvoker _requestInvoker = requestInvoker; + private readonly IFilePathService _filePathService = filePathService; + private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => true; + + public Registration? GetRegistration(VSInternalClientCapabilities clientCapabilities, DocumentFilter[] filter, RazorCohostRequestContext requestContext) + { + // TODO: if (clientCapabilities.TextDocument?.Diagnostic?.DynamicRegistration is true) + { + return new Registration() + { + Method = VSInternalMethods.DocumentPullDiagnosticName, + RegisterOptions = new VSInternalDiagnosticRegistrationOptions() + { + DocumentSelector = filter, + DiagnosticKinds = [VSInternalDiagnosticKind.Syntax] + } + }; + } + + // return null; + } + + protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(VSInternalDocumentDiagnosticsParams request) + => request.TextDocument?.ToRazorTextDocumentIdentifier(); + + protected override Task HandleRequestAsync(VSInternalDocumentDiagnosticsParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync(context.TextDocument.AssumeNotNull(), cancellationToken); + + private async Task HandleRequestAsync(TextDocument razorDocument, CancellationToken cancellationToken) + { + // Diagnostics is a little different, because Roslyn is not designed to run diagnostics in OOP. Their system will transition to OOP + // as it needs, but we have to start here in devenv. This is not as big a problem as it sounds, specifically for diagnostics, because + // we only need to tell Roslyn the document we need diagnostics for. If we had to map positions or ranges etc. it would be worse + // because we'd have to transition to our OOP to find out that info, then back here to get the diagnostics, then back to OOP to process. + _logger.LogDebug($"Getting diagnostics for {razorDocument.FilePath}"); + + var csharpTask = GetCSharpDiagnosticsAsync(razorDocument, cancellationToken); + var htmlTask = GetHtmlDiagnosticsAsync(razorDocument, cancellationToken); + + try + { + await Task.WhenAll(htmlTask, csharpTask).ConfigureAwait(false); + } + catch (Exception e) + { + if (e is not OperationCanceledException) + { + _logger.LogError(e, $"Exception thrown in PullDiagnostic delegation"); + } + // Return null if any of the tasks getting diagnostics results in an error + return null; + } + + var csharpDiagnostics = await csharpTask.ConfigureAwait(false); + var htmlDiagnostics = await htmlTask.ConfigureAwait(false); + + _logger.LogDebug($"Calling OOP with the {csharpDiagnostics.Length} C# and {htmlDiagnostics.Length} Html diagnostics"); + var diagnostics = await _remoteServiceInvoker.TryInvokeAsync>( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => service.GetDiagnosticsAsync(solutionInfo, razorDocument.Id, csharpDiagnostics, htmlDiagnostics, cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (diagnostics.IsDefaultOrEmpty) + { + return null; + } + + return + [ + new() + { + Diagnostics = diagnostics.ToArray(), + ResultId = Guid.NewGuid().ToString() + } + ]; + } + + private Task> GetCSharpDiagnosticsAsync(TextDocument razorDocument, CancellationToken cancellationToken) + { + // TODO: This code will not work when the source generator is hooked up. + // How do we get the source generated C# document without OOP? Can we reverse engineer a file path? + var projectKey = razorDocument.Project.ToProjectKey(); + var csharpFilePath = _filePathService.GetRazorCSharpFilePath(projectKey, razorDocument.FilePath.AssumeNotNull()); + // We put the project Id in the generated document path, so there can only be one document + if (razorDocument.Project.Solution.GetDocumentIdsWithFilePath(csharpFilePath) is not [{ } generatedDocumentId] || + razorDocument.Project.GetDocument(generatedDocumentId) is not { } generatedDocument) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + _logger.LogDebug($"Getting C# diagnostics for {generatedDocument.FilePath}"); + return ExternalHandlers.Diagnostics.GetDocumentDiagnosticsAsync(generatedDocument, supportsVisualStudioExtensions: true, cancellationToken); + } + + private async Task> GetHtmlDiagnosticsAsync(TextDocument razorDocument, CancellationToken cancellationToken) + { + var htmlDocument = await _htmlDocumentSynchronizer.TryGetSynchronizedHtmlDocumentAsync(razorDocument, cancellationToken).ConfigureAwait(false); + if (htmlDocument is null) + { + return []; + } + + var diagnosticsParams = new VSInternalDocumentDiagnosticsParams + { + TextDocument = new TextDocumentIdentifier { Uri = htmlDocument.Uri } + }; + + _logger.LogDebug($"Getting Html diagnostics for {htmlDocument.Uri}"); + + var result = await _requestInvoker.ReinvokeRequestOnServerAsync( + htmlDocument.Buffer, + VSInternalMethods.DocumentPullDiagnosticName, + RazorLSPConstants.HtmlLanguageServerName, + diagnosticsParams, + cancellationToken).ConfigureAwait(false); + + if (result?.Response is null) + { + return []; + } + + // This is, to say the least, not ideal. In future we're going to normalize on to Roslyn LSP types, and this can go. + var options = new JsonSerializerOptions(); + foreach (var converter in RazorServiceDescriptorsWrapper.GetLspConverters()) + { + options.Converters.Add(converter); + } + + var hmlDiagnostics = JsonSerializer.Deserialize(JsonSerializer.SerializeToDocument(result.Response), options); + if (hmlDiagnostics is not { } convertedHtmlDiagnostics) + { + return []; + } + + using var allDiagnostics = new PooledArrayBuilder(); + foreach (var report in convertedHtmlDiagnostics) + { + if (report.Diagnostics is not null) + { + allDiagnostics.AddRange(report.Diagnostics); + } + } + + return allDiagnostics.ToImmutable(); + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostDocumentPullDiagnosticsEndpoint instance) + { + public Task HandleRequestAsync(TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(razorDocument, cancellationToken); + } +} +