diff --git a/eng/targets/Services.props b/eng/targets/Services.props index 068c5f2e0ab..ede89dc2165 100644 --- a/eng/targets/Services.props +++ b/eng/targets/Services.props @@ -23,5 +23,6 @@ + diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs index d28cb50e47c..f6c83b1a43a 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs @@ -178,7 +178,6 @@ static void AddHandlers(IServiceCollection services, LanguageServerFeatureOption services.AddTransient(sp => sp.GetRequiredService()); services.AddHandlerWithCapabilities(); - services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); @@ -186,6 +185,7 @@ static void AddHandlers(IServiceCollection services, LanguageServerFeatureOption if (!featureOptions.UseRazorCohostServer) { + services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionExtensions.cs index 0e547fe85a1..e8d877c4ff5 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; +using RLSP = Roslyn.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Workspaces; @@ -12,6 +13,9 @@ internal static class LinePositionExtensions public static Position ToPosition(this LinePosition linePosition) => new Position(linePosition.Line, linePosition.Character); + public static RLSP.Position ToRLSPPosition(this LinePosition linePosition) + => new RLSP.Position(linePosition.Line, linePosition.Character); + public static bool TryGetAbsoluteIndex(this LinePosition position, SourceText sourceText, ILogger logger, out int absoluteIndex) => sourceText.TryGetAbsoluteIndex(position.Line, position.Character, logger, out absoluteIndex); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionSpanExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionSpanExtensions.cs index ba6e6f19b29..b446affd1be 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionSpanExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LinePositionSpanExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; +using RLSP = Roslyn.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Workspaces; @@ -15,6 +16,13 @@ public static Range ToRange(this LinePositionSpan linePositionSpan) End = linePositionSpan.End.ToPosition() }; + public static RLSP.Range ToRLSPRange(this LinePositionSpan linePositionSpan) + => new RLSP.Range + { + Start = linePositionSpan.Start.ToRLSPPosition(), + End = linePositionSpan.End.ToRLSPPosition() + }; + public static TextSpan ToTextSpan(this LinePositionSpan linePositionSpan, SourceText sourceText) => sourceText.GetTextSpan(linePositionSpan.Start.Line, linePositionSpan.Start.Character, linePositionSpan.End.Line, linePositionSpan.End.Character); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/PositionExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/PositionExtensions.cs index 767b310176a..dc71fc530b5 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/PositionExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/PositionExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; +using RLSP = Roslyn.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Workspaces; @@ -15,6 +16,9 @@ internal static class PositionExtensions public static LinePosition ToLinePosition(this Position position) => new LinePosition(position.Line, position.Character); + public static LinePosition ToLinePosition(this RLSP.Position position) + => new LinePosition(position.Line, position.Character); + public static bool TryGetAbsoluteIndex(this Position position, SourceText sourceText, ILogger? logger, out int absoluteIndex) { if (position is null) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RangeExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RangeExtensions.cs index 66ac2168234..4f3504b6f8f 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RangeExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RangeExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; +using RLSP = Roslyn.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Workspaces; @@ -110,6 +111,9 @@ public static TextSpan ToTextSpan(this Range range, SourceText sourceText) public static LinePositionSpan ToLinePositionSpan(this Range range) => new LinePositionSpan(range.Start.ToLinePosition(), range.End.ToLinePosition()); + public static LinePositionSpan ToLinePositionSpan(this RLSP.Range range) + => new LinePositionSpan(range.Start.ToLinePosition(), range.End.ToLinePosition()); + public static bool IsUndefined(this Range range) { if (range is null) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/DocumentHighlight/RemoteDocumentHighlight.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/DocumentHighlight/RemoteDocumentHighlight.cs new file mode 100644 index 00000000000..a0494059d38 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/DocumentHighlight/RemoteDocumentHighlight.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Runtime.Serialization; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using RLSP = Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.Razor.Protocol.DocumentHighlight; + +using DocumentHighlight = VisualStudio.LanguageServer.Protocol.DocumentHighlight; + +[DataContract] +internal readonly record struct RemoteDocumentHighlight( + [property: DataMember(Order = 0)] LinePositionSpan Position, + [property: DataMember(Order = 1)] DocumentHighlightKind Kind) +{ + public static RemoteDocumentHighlight FromRLSPDocumentHighlight(RLSP.DocumentHighlight h) + => new RemoteDocumentHighlight(h.Range.ToLinePositionSpan(), (DocumentHighlightKind)h.Kind); + + public static DocumentHighlight ToLspDocumentHighlight(RemoteDocumentHighlight r) + => new DocumentHighlight + { + Range = r.Position.ToRange(), + Kind = r.Kind + }; +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteDocumentHighlightService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteDocumentHighlightService.cs new file mode 100644 index 00000000000..b49b4b86889 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteDocumentHighlightService.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Protocol.DocumentHighlight; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Razor.Remote; + +internal interface IRemoteDocumentHighlightService +{ + ValueTask> GetHighlightsAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId documentId, LinePosition position, CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteUriPresentationService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteUriPresentationService.cs index b5273b77ccb..0c0716604a9 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteUriPresentationService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteUriPresentationService.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; -using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.ExternalAccess.Razor; @@ -12,10 +11,6 @@ namespace Microsoft.CodeAnalysis.Razor.Remote; internal interface IRemoteUriPresentationService { - ValueTask GetPresentationAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, LinePositionSpan span, Uri[]? uris, CancellationToken cancellationToken); + ValueTask> GetPresentationAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, LinePositionSpan span, Uri[]? uris, CancellationToken cancellationToken); - [DataContract] - public record struct Response( - [property: DataMember(Order = 0)] bool CallHtml, - [property: DataMember(Order = 1)] TextChange? TextChange); } 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 5b79b2b5005..644881de001 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs @@ -1,6 +1,8 @@ // 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 Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; using Microsoft.CodeAnalysis.ExternalAccess.Razor; @@ -8,14 +10,8 @@ namespace Microsoft.CodeAnalysis.Razor.Remote; internal static class RazorServices { - private const string ComponentName = "Razor"; - - public static readonly RazorServiceDescriptorsWrapper Descriptors = new( - ComponentName, - featureDisplayNameProvider: feature => $"Razor {feature} Feature", - additionalFormatters: [], - additionalResolvers: TopLevelResolvers.All, - interfaces: + // Internal for testing + internal static readonly IEnumerable<(Type, Type?)> MessagePackServices = [ (typeof(IRemoteLinkedEditingRangeService), null), (typeof(IRemoteTagHelperProviderService), null), @@ -23,15 +19,33 @@ internal static class RazorServices (typeof(IRemoteSemanticTokensService), null), (typeof(IRemoteHtmlDocumentService), null), (typeof(IRemoteUriPresentationService), null), - (typeof(IRemoteFoldingRangeService), null) - ]); + (typeof(IRemoteFoldingRangeService), null), + (typeof(IRemoteDocumentHighlightService), null), + ]; + + // Internal for testing + internal static readonly IEnumerable<(Type, Type?)> JsonServices = + [ + (typeof(IRemoteSignatureHelpService), null), + ]; + + private const string ComponentName = "Razor"; + + public static readonly RazorServiceDescriptorsWrapper Descriptors = new( + ComponentName, + featureDisplayNameProvider: GetFeatureDisplayName, + additionalFormatters: [], + additionalResolvers: TopLevelResolvers.All, + interfaces: MessagePackServices); public static readonly RazorServiceDescriptorsWrapper JsonDescriptors = new( ComponentName, // Needs to match the above because so much of our ServiceHub infrastructure is convention based - featureDisplayNameProvider: feature => $"Razor {feature} Feature", + featureDisplayNameProvider: GetFeatureDisplayName, jsonConverters: RazorServiceDescriptorsWrapper.GetLspConverters(), - interfaces: - [ - (typeof(IRemoteSignatureHelpService), null), - ]); + interfaces: JsonServices); + + private static string GetFeatureDisplayName(string feature) + { + return $"Razor {feature} Feature"; + } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RemoteResponse.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RemoteResponse.cs new file mode 100644 index 00000000000..b22ade06a3b --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RemoteResponse.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Runtime.Serialization; + +namespace Microsoft.CodeAnalysis.Razor.Remote; + +[DataContract] +internal record struct RemoteResponse( + [property: DataMember(Order = 0)] bool StopHandling, + [property: DataMember(Order = 1)] T Result) +{ + public static RemoteResponse CallHtml => new(StopHandling: false, Result: default!); + public static RemoteResponse NoFurtherHandling => new(StopHandling: true, Result: default!); + public static RemoteResponse Results(T result) => new(StopHandling: false, Result: result); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/DocumentHighlight/RemoteDocumentHighlightService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/DocumentHighlight/RemoteDocumentHighlightService.cs new file mode 100644 index 00000000000..5c940f5dbb8 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/DocumentHighlight/RemoteDocumentHighlightService.cs @@ -0,0 +1,92 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Razor.Protocol.DocumentHighlight; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Response = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse; + +namespace Microsoft.CodeAnalysis.Remote.Razor; + +internal sealed partial class RemoteDocumentHighlightService(in ServiceArgs args) : RazorDocumentServiceBase(in args), IRemoteDocumentHighlightService +{ + internal sealed class Factory : FactoryBase + { + protected override IRemoteDocumentHighlightService CreateService(in ServiceArgs args) + => new RemoteDocumentHighlightService(in args); + } + + private readonly IRazorDocumentMappingService _documentMappingService = args.ExportProvider.GetExportedValue(); + private readonly IFilePathService _filePathService = args.ExportProvider.GetExportedValue(); + + public ValueTask GetHighlightsAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId razorDocumentId, + LinePosition position, + CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + razorDocumentId, + context => GetHighlightsAsync(context, position, cancellationToken), + cancellationToken); + + private async ValueTask GetHighlightsAsync( + RemoteDocumentContext context, + LinePosition position, + CancellationToken cancellationToken) + { + var sourceText = await context.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); + if (!sourceText.TryGetAbsoluteIndex(position.Line, position.Character, out var index)) + { + return Response.NoFurtherHandling; + } + + var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + + var languageKind = _documentMappingService.GetLanguageKind(codeDocument, index, rightAssociative: true); + if (languageKind is RazorLanguageKind.Html) + { + return Response.CallHtml; + } + else if (languageKind is RazorLanguageKind.Razor) + { + return Response.NoFurtherHandling; + } + + var csharpDocument = codeDocument.GetCSharpDocument(); + if (_documentMappingService.TryMapToGeneratedDocumentPosition(csharpDocument, index, out var mappedPosition, out _)) + { + var generatedDocument = await context.GetGeneratedDocumentAsync(_filePathService, cancellationToken).ConfigureAwait(false); + + var highlights = await DocumentHighlights.GetHighlightsAsync(generatedDocument, mappedPosition, cancellationToken).ConfigureAwait(false); + + if (highlights is not null) + { + using var results = new PooledArrayBuilder(); + + foreach (var highlight in highlights) + { + if (_documentMappingService.TryMapToHostDocumentRange(csharpDocument, highlight.Range.ToLinePositionSpan(), out var mappedRange)) + { + highlight.Range = mappedRange.ToRLSPRange(); + results.Add(RemoteDocumentHighlight.FromRLSPDocumentHighlight(highlight)); + } + } + + return Response.Results(results.ToArray()); + } + } + + return Response.NoFurtherHandling; + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs index 290c61a0a5b..632f4b59e83 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs @@ -12,6 +12,7 @@ using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Text; +using Response = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse; namespace Microsoft.CodeAnalysis.Remote.Razor; @@ -25,7 +26,7 @@ protected override IRemoteUriPresentationService CreateService(in ServiceArgs ar private readonly IRazorDocumentMappingService _documentMappingService = args.ExportProvider.GetExportedValue(); - public ValueTask GetPresentationAsync( + public ValueTask GetPresentationAsync( RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, LinePositionSpan span, @@ -37,11 +38,7 @@ protected override IRemoteUriPresentationService CreateService(in ServiceArgs ar context => GetPresentationAsync(context, span, uris, cancellationToken), cancellationToken); - private static IRemoteUriPresentationService.Response CallHtml => new(CallHtml: true, TextChange: null); - private static IRemoteUriPresentationService.Response NoFurtherHandling => new(CallHtml: false, TextChange: null); - private static IRemoteUriPresentationService.Response TextChange(TextChange textChange) => new(CallHtml: false, TextChange: textChange); - - private async ValueTask GetPresentationAsync( + private async ValueTask GetPresentationAsync( RemoteDocumentContext context, LinePositionSpan span, Uri[]? uris, @@ -51,7 +48,7 @@ protected override IRemoteUriPresentationService CreateService(in ServiceArgs ar if (!sourceText.TryGetAbsoluteIndex(span.Start.Line, span.Start.Character, out var index)) { // If the position is invalid then we shouldn't expect to be able to handle a Html response - return NoFurtherHandling; + return Response.NoFurtherHandling; } var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); @@ -63,13 +60,13 @@ protected override IRemoteUriPresentationService CreateService(in ServiceArgs ar // our support for Uri presentation is to insert a Html tag, so we only support Html // If Roslyn add support in future then this is where it would go. - return NoFurtherHandling; + return Response.NoFurtherHandling; } var razorFileUri = UriPresentationHelper.GetComponentFileNameFromUriPresentationRequest(uris, Logger); if (razorFileUri is null) { - return CallHtml; + return Response.CallHtml; } var solution = context.TextDocument.Project.Solution; @@ -79,14 +76,14 @@ protected override IRemoteUriPresentationService CreateService(in ServiceArgs ar var ids = solution.GetDocumentIdsWithFilePath(uriToFind); if (ids.Length == 0) { - return CallHtml; + return Response.CallHtml; } // We assume linked documents would produce the same component tag so just take the first var otherDocument = solution.GetAdditionalDocument(ids[0]); if (otherDocument is null) { - return CallHtml; + return Response.CallHtml; } var otherSnapshot = DocumentSnapshotFactory.GetOrCreate(otherDocument); @@ -94,15 +91,15 @@ protected override IRemoteUriPresentationService CreateService(in ServiceArgs ar if (descriptor is null) { - return CallHtml; + return Response.CallHtml; } var tag = descriptor.TryGetComponentTag(); if (tag is null) { - return CallHtml; + return Response.CallHtml; } - return TextChange(new TextChange(span.ToTextSpan(sourceText), tag)); + return Response.Results(new TextChange(span.ToTextSpan(sourceText), tag)); } } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentHighlightPresentationEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentHighlightPresentationEndpoint.cs new file mode 100644 index 00000000000..568382eaa3d --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentHighlightPresentationEndpoint.cs @@ -0,0 +1,109 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Protocol.DocumentHighlight; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.LanguageClient.Extensions; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(Methods.TextDocumentDocumentHighlightName)] +[Export(typeof(IDynamicRegistrationProvider))] +[ExportCohostStatelessLspService(typeof(CohostDocumentHighlightEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal class CohostDocumentHighlightEndpoint( + IRemoteServiceInvoker remoteServiceInvoker, + IHtmlDocumentSynchronizer htmlDocumentSynchronizer, + LSPRequestInvoker requestInvoker) + : AbstractRazorCohostDocumentRequestHandler, IDynamicRegistrationProvider +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + private readonly IHtmlDocumentSynchronizer _htmlDocumentSynchronizer = htmlDocumentSynchronizer; + private readonly LSPRequestInvoker _requestInvoker = requestInvoker; + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => true; + + public Registration? GetRegistration(VSInternalClientCapabilities clientCapabilities, DocumentFilter[] filter, RazorCohostRequestContext requestContext) + { + if (clientCapabilities.SupportsVisualStudioExtensions) + { + return new Registration + { + Method = Methods.TextDocumentDocumentHighlightName, + RegisterOptions = new DocumentHighlightRegistrationOptions() + { + DocumentSelector = filter + } + }; + } + + return null; + } + + protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(DocumentHighlightParams request) + => request.TextDocument.ToRazorTextDocumentIdentifier(); + + protected override Task HandleRequestAsync(DocumentHighlightParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync(request, context.TextDocument.AssumeNotNull(), cancellationToken); + + private async Task HandleRequestAsync(DocumentHighlightParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + var csharpResult = await _remoteServiceInvoker.TryInvokeAsync>( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => service.GetHighlightsAsync(solutionInfo, razorDocument.Id, request.Position.ToLinePosition(), cancellationToken), + cancellationToken).ConfigureAwait(false); + + // If we got a response back, then either Razor or C# wants to do something with this, so we're good to go + if (csharpResult.Result is { } highlights) + { + return highlights.Select(RemoteDocumentHighlight.ToLspDocumentHighlight).ToArray(); + } + + if (csharpResult.StopHandling) + { + return null; + } + + // If we didn't get anything from Razor or Roslyn, lets ask Html what they want to do + var htmlDocument = await _htmlDocumentSynchronizer.TryGetSynchronizedHtmlDocumentAsync(razorDocument, cancellationToken).ConfigureAwait(false); + if (htmlDocument is null) + { + return null; + } + + request.TextDocument = request.TextDocument.WithUri(htmlDocument.Uri); + + var result = await _requestInvoker.ReinvokeRequestOnServerAsync( + htmlDocument.Buffer, + Methods.TextDocumentDocumentHighlightName, + RazorLSPConstants.HtmlLanguageServerName, + request, + cancellationToken).ConfigureAwait(false); + + // Since we don't need to map positions in Html, and document highlight results don't have Uri's, we can return these results directly + return result?.Response; + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostDocumentHighlightEndpoint instance) + { + public Task HandleRequestAsync(DocumentHighlightParams request, TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(request, razorDocument, cancellationToken); + } +} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostUriPresentationEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostUriPresentationEndpoint.cs index ac01c73ecbd..9dc498ad081 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostUriPresentationEndpoint.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostUriPresentationEndpoint.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; using Microsoft.CodeAnalysis.Razor.Remote; using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; using Microsoft.VisualStudio.LanguageServer.Protocol; using Microsoft.VisualStudio.Razor.LanguageClient.Extensions; @@ -63,13 +64,13 @@ internal class CohostUriPresentationEndpoint( private async Task HandleRequestAsync(VSInternalUriPresentationParams request, TextDocument razorDocument, CancellationToken cancellationToken) { - var data = await _remoteServiceInvoker.TryInvokeAsync( + var data = await _remoteServiceInvoker.TryInvokeAsync>( razorDocument.Project.Solution, (service, solutionInfo, cancellationToken) => service.GetPresentationAsync(solutionInfo, razorDocument.Id, request.Range.ToLinePositionSpan(), request.Uris, cancellationToken), cancellationToken).ConfigureAwait(false); // If we got a response back, then we're good to go - if (data.TextChange is { } textChange) + if (data.Result is { } textChange) { var sourceText = await razorDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); @@ -90,7 +91,7 @@ internal class CohostUriPresentationEndpoint( } // If we didn't get anything from our logic, we might need to go and ask Html, but we also might have determined not to - if (!data.CallHtml) + if (data.StopHandling) { return null; } diff --git a/src/Razor/test/Microsoft.CodeAnalysis.Remote.Razor.Test/RazorServicesTest.cs b/src/Razor/test/Microsoft.CodeAnalysis.Remote.Razor.Test/RazorServicesTest.cs new file mode 100644 index 00000000000..40215a782ac --- /dev/null +++ b/src/Razor/test/Microsoft.CodeAnalysis.Remote.Razor.Test/RazorServicesTest.cs @@ -0,0 +1,115 @@ +// 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.Linq; +using System.Xml; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.CodeAnalysis.Remote.Razor; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.CodeAnalysis.Razor.Remote; + +public class RazorServicesTest(ITestOutputHelper testOutputHelper) : ToolingTestBase(testOutputHelper) +{ + private readonly static XmlDocument s_servicesFile = LoadServicesFile(); + + [Theory] + [MemberData(nameof(MessagePackServices))] + public void MessagePackServicesAreListedProperly(Type serviceType, Type? callbackType) + { + VerifyService(serviceType, callbackType); + } + + [Theory] + [MemberData(nameof(JsonServices))] + public void JsonServicesAreListedProperly(Type serviceType, Type? callbackType) + { + Assert.True(typeof(IRemoteJsonService).IsAssignableFrom(serviceType)); + VerifyService(serviceType, callbackType); + } + + [Fact] + public void RazorServicesContainsAllServices() + { + var services = new HashSet(RazorServices.MessagePackServices.Select(s => s.Item1.Name)); + services.UnionWith(RazorServices.JsonServices.Select(s => s.Item1.Name)); + var serviceNodes = s_servicesFile.SelectNodes("/Project/ItemGroup/ServiceHubService"); + Assert.NotNull(serviceNodes); + foreach (XmlNode serviceNode in serviceNodes) + { + Assert.NotNull(serviceNode); + Assert.NotNull(serviceNode.Attributes); + + var serviceEntry = serviceNode.Attributes["Include"]!.Value; + var factoryName = serviceNode.Attributes["ClassName"]!.Value; + + var factoryType = typeof(ServiceArgs).Assembly.GetType(factoryName); + AssertEx.NotNull(factoryType, $"Could not load type for factory '{factoryName}'"); + + var interfaceType = factoryType.BaseType!.GetGenericArguments()[0]; + Assert.True(services.Contains(interfaceType.Name), $"Service '{interfaceType.Name}' is not listed in RazorServices"); + } + } + + public static IEnumerable MessagePackServices() + { + foreach (var service in RazorServices.MessagePackServices) + { + yield return [service.Item1, service.Item2]; + } + } + + public static IEnumerable JsonServices() + { + foreach (var service in RazorServices.JsonServices) + { + yield return [service.Item1, service.Item2]; + } + } + + private static XmlDocument LoadServicesFile() + { + var document = new XmlDocument(); + document.Load(Path.Combine(TestProject.GetRepoRoot(), "eng", "targets", "Services.props")); + return document; + } + + private static void VerifyService(Type serviceType, Type? callbackType) + { + const string prefix = "IRemote"; + const string suffix = "Service"; + + Assert.Null(callbackType); + + var serviceName = serviceType.Name; + Assert.StartsWith(prefix, serviceName); + Assert.EndsWith(suffix, serviceName); + + var shortName = serviceName.Substring(prefix.Length, serviceName.Length - prefix.Length - suffix.Length); + var servicePropsEntry = $"Microsoft.VisualStudio.Razor.{shortName}"; + + var serviceNode = s_servicesFile.SelectSingleNode($"/Project/ItemGroup/ServiceHubService[@Include='{servicePropsEntry}']"); + AssertEx.NotNull(serviceNode, $"Expected entry in Services.props for {servicePropsEntry}"); + + var serviceImplName = $"Microsoft.CodeAnalysis.Remote.Razor.Remote{shortName}Service"; + var factoryName = serviceNode.Attributes!["ClassName"]!.Value; + Assert.Equal($"{serviceImplName}+Factory", factoryName); + + var serviceImplType = typeof(ServiceArgs).Assembly.GetType(serviceImplName); + Assert.NotNull(serviceImplType); + + var factoryType = typeof(ServiceArgs).Assembly.GetType(factoryName); + Assert.NotNull(factoryType); + + Assert.True(serviceType.IsAssignableFrom(serviceImplType)); + + var interfaceType = factoryType.BaseType!.GetGenericArguments()[0]; + Assert.Equal(serviceType, interfaceType); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentHighlightEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentHighlightEndpointTest.cs new file mode 100644 index 00000000000..ef1c4e4777c --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentHighlightEndpointTest.cs @@ -0,0 +1,179 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +public class CohostDocumentHighlightEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) +{ + [Fact] + public async Task Local() + { + var input = """ +
+ + @{ + var $$[|myVariable|] = "Hello"; + + var length = [|myVariable|].Length; + } + """; + + await VerifyDocumentHighlightsAsync(input); + } + + [Fact] + public async Task Method() + { + var input = """ +
+ + @code + { + void [|Method|]() + { + $$[|Method|](); + } + } + """; + + await VerifyDocumentHighlightsAsync(input); + } + + [Fact] + public async Task AttributeToField() + { + var input = """ +
+
+
+
+ + @code + { + private string [|_className|] = "hello"; + } + """; + + await VerifyDocumentHighlightsAsync(input); + } + + [Fact] + public async Task FieldToAttribute() + { + var input = """ +
+
+
+
+ + @code + { + private string $$[|_className|] = "hello"; + } + """; + + await VerifyDocumentHighlightsAsync(input); + } + + [Fact] + public async Task Html() + { + var input = """ +
+ +
+ + + @code + { + private string _className = "hello"; + } + """; + + await VerifyDocumentHighlightsAsync(input, htmlResponse: [new DocumentHighlight()]); + } + + [Fact] + public async Task Razor() + { + var input = """ + @in$$ject IDisposable Disposable + +
+
+
+
+ + @code + { + private string _className = "hello"; + } + """; + + await VerifyDocumentHighlightsAsync(input); + } + + [Fact] + public async Task Inject() + { + var input = """ + @inject [|IDis$$posable|] Disposable + +
+
+ + @code + { + [|IDisposable|].Dispose() + { + } + } + """; + + await VerifyDocumentHighlightsAsync(input); + } + + private async Task VerifyDocumentHighlightsAsync(string input, DocumentHighlight[]? htmlResponse = null) + { + TestFileMarkupParser.GetPositionAndSpans(input, out var source, out int cursorPosition, out ImmutableArray spans); + var document = CreateProjectAndRazorDocument(source); + var inputText = await document.GetTextAsync(DisposalToken); + inputText.GetLineAndOffset(cursorPosition, out var lineIndex, out var characterIndex); + + var requestInvoker = new TestLSPRequestInvoker([(Methods.TextDocumentDocumentHighlightName, htmlResponse)]); + + var endpoint = new CohostDocumentHighlightEndpoint(RemoteServiceInvoker, TestHtmlDocumentSynchronizer.Instance, requestInvoker); + + var request = new DocumentHighlightParams() + { + TextDocument = new TextDocumentIdentifier() { Uri = document.CreateUri() }, + Position = new Position(lineIndex, characterIndex) + }; + + var result = await endpoint.GetTestAccessor().HandleRequestAsync(request, document, DisposalToken); + + if (spans.Length == 0) + { + Assert.Same(result, htmlResponse); + return; + } + + Assert.NotNull(result); + + var actual = TestFileMarkupParser.CreateTestFile(source, cursorPosition, result.SelectAsArray(h => h.Range.ToTextSpan(inputText))); + + AssertEx.EqualOrDiff(input, actual); + } +} diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestProject.cs b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestProject.cs index 2de9f92f000..fe15f6c91e2 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestProject.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestProject.cs @@ -50,6 +50,12 @@ public static string GetProjectDirectory(string directoryHint, Layer layer, bool return projectDirectory; } + public static string GetRepoRoot(bool useCurrentDirectory = false) + { + var baseDir = useCurrentDirectory ? Directory.GetCurrentDirectory() : AppContext.BaseDirectory; + return SearchUp(baseDir, "global.json"); + } + public static string GetProjectDirectory(Type type, Layer layer, bool useCurrentDirectory = false) { var baseDir = useCurrentDirectory ? Directory.GetCurrentDirectory() : AppContext.BaseDirectory; @@ -57,8 +63,8 @@ public static string GetProjectDirectory(Type type, Layer layer, bool useCurrent var repoRoot = SearchUp(baseDir, "global.json"); var assemblyName = type.Assembly.GetName().Name; var projectDirectory = layer == Layer.Compiler - ? Path.Combine(repoRoot, "src", layerFolderName, assemblyName, "test") - : Path.Combine(repoRoot, "src", layerFolderName, "test", assemblyName); + ? Path.Combine(repoRoot, "src", layerFolderName, assemblyName, "test") + : Path.Combine(repoRoot, "src", layerFolderName, "test", assemblyName); if (string.Equals(assemblyName, "Microsoft.AspNetCore.Razor.Language.Test", StringComparison.Ordinal)) {