diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/IBrokeredServiceInterceptor.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/IBrokeredServiceInterceptor.cs new file mode 100644 index 00000000000..a2a3c23a48b --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/IBrokeredServiceInterceptor.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; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Api; + +namespace Microsoft.CodeAnalysis.Remote.Razor; + +/// +/// An abstraction to avoid calling the static helper defined in Roslyn +/// +internal interface IBrokeredServiceInterceptor +{ + ValueTask RunServiceAsync(Func implementation, CancellationToken cancellationToken); + + ValueTask RunServiceAsync(RazorPinnedSolutionInfoWrapper solutionInfo, Func> implementation, CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Microsoft.CodeAnalysis.Remote.Razor.csproj b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Microsoft.CodeAnalysis.Remote.Razor.csproj index e56c17ac547..3357fd1eb79 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Microsoft.CodeAnalysis.Remote.Razor.csproj +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Microsoft.CodeAnalysis.Remote.Razor.csproj @@ -54,6 +54,7 @@ + diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorServiceBase.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorServiceBase.cs index 28f1d6accbb..35855fe0acb 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorServiceBase.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorServiceBase.cs @@ -13,17 +13,19 @@ namespace Microsoft.CodeAnalysis.Remote.Razor; internal abstract class RazorServiceBase : IDisposable { private readonly ServiceBrokerClient _serviceBrokerClient; + private readonly IBrokeredServiceInterceptor? _brokeredServiceInterceptor; public RazorServiceBase(IServiceBroker serviceBroker) { + _brokeredServiceInterceptor = serviceBroker as IBrokeredServiceInterceptor; _serviceBrokerClient = new ServiceBrokerClient(serviceBroker, joinableTaskFactory: null); } protected ValueTask RunServiceAsync(Func implementation, CancellationToken cancellationToken) - => RazorBrokeredServiceImplementation.RunServiceAsync(implementation, cancellationToken); + => _brokeredServiceInterceptor?.RunServiceAsync(implementation, cancellationToken) ?? RazorBrokeredServiceImplementation.RunServiceAsync(implementation, cancellationToken); protected ValueTask RunServiceAsync(RazorPinnedSolutionInfoWrapper solutionInfo, Func> implementation, CancellationToken cancellationToken) - => RazorBrokeredServiceImplementation.RunServiceAsync(solutionInfo, _serviceBrokerClient, implementation, cancellationToken); + => _brokeredServiceInterceptor?.RunServiceAsync(solutionInfo, implementation, cancellationToken) ?? RazorBrokeredServiceImplementation.RunServiceAsync(solutionInfo, _serviceBrokerClient, implementation, cancellationToken); public void Dispose() { diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostLinkedEditingRangeEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostLinkedEditingRangeEndpoint.cs index a05924cdf51..a288d8c4883 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostLinkedEditingRangeEndpoint.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostLinkedEditingRangeEndpoint.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; using Microsoft.CodeAnalysis.Razor.LinkedEditingRange; using Microsoft.CodeAnalysis.Razor.Logging; @@ -53,10 +54,11 @@ internal class CohostLinkedEditingRangeEndpoint(IRemoteServiceProvider remoteSer protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(LinkedEditingRangeParams request) => request.TextDocument.ToRazorTextDocumentIdentifier(); - protected override async Task HandleRequestAsync(LinkedEditingRangeParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) - { - var razorDocument = context.TextDocument.AssumeNotNull(); + protected override Task HandleRequestAsync(LinkedEditingRangeParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync(request, context.TextDocument.AssumeNotNull(), cancellationToken); + private async Task HandleRequestAsync(LinkedEditingRangeParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { var linkedRanges = await _remoteServiceProvider.TryInvokeAsync( razorDocument.Project.Solution, (service, solutionInfo, cancellationToken) => service.GetRangesAsync(solutionInfo, razorDocument.Id, request.Position.ToLinePosition(), cancellationToken), @@ -73,4 +75,12 @@ internal class CohostLinkedEditingRangeEndpoint(IRemoteServiceProvider remoteSer return null; } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostLinkedEditingRangeEndpoint instance) + { + public Task HandleRequestAsync(LinkedEditingRangeParams request, TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(request, razorDocument, cancellationToken); + } } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Remote/RemoteServiceProvider.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Remote/RemoteServiceProvider.cs index 7f3a4c438f7..77361646138 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Remote/RemoteServiceProvider.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Remote/RemoteServiceProvider.cs @@ -71,7 +71,7 @@ internal sealed class RemoteServiceProvider( catch (Exception ex) when (ex is not OperationCanceledException) { var approximateCallingClassName = Path.GetFileNameWithoutExtension(callerFilePath); - _logger.LogError(ex, $"Error calling remote method for {typeof(TService).Name} service, invocation: ${approximateCallingClassName}.{callerMemberName}"); + _logger.LogError(ex, $"Error calling remote method for {typeof(TService).Name} service, invocation: {approximateCallingClassName}.{callerMemberName}"); _telemetryReporter.ReportFault(ex, "Exception calling remote method for {service}, invocation: {class}.{method}", typeof(TService).FullName, approximateCallingClassName, callerMemberName); return default; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/ToolingTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/ToolingTestBase.cs index 1cac6d4b4ff..8dad3a87185 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/ToolingTestBase.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/ToolingTestBase.cs @@ -70,6 +70,12 @@ static ToolingTestBase() /// internal ILoggerFactory LoggerFactory { get; } + /// + /// An that the currently running test can use to write + /// though using is probably preferred. + /// + internal ITestOutputHelper TestOutputHelper { get; } + private ILogger? _logger; /// @@ -86,6 +92,7 @@ protected ToolingTestBase(ITestOutputHelper testOutput) _disposalTokenSource = new(); DisposalToken = _disposalTokenSource.Token; + TestOutputHelper = testOutput; LoggerFactory = new TestOutputLoggerFactory(testOutput); // Give this thread a name, so it's easier to find in the VS Threads window. diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostLinkedEditingRangeTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostLinkedEditingRangeTest.cs new file mode 100644 index 00000000000..b84ad7a36ee --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostLinkedEditingRangeTest.cs @@ -0,0 +1,176 @@ +// 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.Tasks; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.LinkedEditingRange; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.LanguageClient.Cohost; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost; + +public class CohostLinkedEditingRangeTest(ITestOutputHelper testOutputHelper) : CohostTestBase(testOutputHelper) +{ + [Theory] + [InlineData("$$div")] + [InlineData("di$$v")] + [InlineData("div$$")] + public async Task Html_StartTag(string startTagAndCursorLocation) + { + var input = $""" + This is a Razor document. + + <[|{startTagAndCursorLocation}|]> + Here is some content. + + + The end. + """; + + await VerifyLinkedEditingRangeAsync(input); + } + + [Theory] + [InlineData("$$div")] + [InlineData("di$$v")] + [InlineData("div$$")] + public async Task Html_EndTag(string endTagAndCursorLocation) + { + var input = $""" + This is a Razor document. + + <[|div|]> + Here is some content. + + + The end. + """; + + await VerifyLinkedEditingRangeAsync(input); + } + + [Fact] + public async Task Html_EndTag_BeforeSlash() + { + var input = $""" + This is a Razor document. + +
+ Here is some content. + <$$/div> + + The end. + """; + + await VerifyLinkedEditingRangeAsync(input); + } + + [Fact] + public async Task Html_NotATag() + { + var input = $""" + This is a $$Razor document. + +
+ Here is some content. +
+ + The end. + """; + + await VerifyLinkedEditingRangeAsync(input); + } + + [Fact] + public async Task Html_NestedTags_Outer() + { + var input = $""" + This is a Razor document. + + <[|d$$iv|]> +
+ Here is some content. +
+ + + The end. + """; + + await VerifyLinkedEditingRangeAsync(input); + } + + [Fact] + public async Task Html_NestedTags_Inner() + { + var input = $""" + This is a Razor document. + +
+ <[|d$$iv|]> + Here is some content. + +
+ + The end. + """; + + await VerifyLinkedEditingRangeAsync(input); + } + + [Fact] + public async Task Html_SelfClosingTag() + { + var input = $""" + This is a Razor document. + + + Here is some content. + + The end. + """; + + await VerifyLinkedEditingRangeAsync(input); + } + + private async Task VerifyLinkedEditingRangeAsync(string input) + { + TestFileMarkupParser.GetPositionAndSpans(input, out input, out int cursorPosition, out ImmutableArray spans); + var document = CreateRazorDocument(input); + var sourceText = await document.GetTextAsync(DisposalToken); + sourceText.GetLineAndOffset(cursorPosition, out var lineIndex, out var characterIndex); + + var endpoint = new CohostLinkedEditingRangeEndpoint(RemoteServiceProvider, LoggerFactory); + + var request = new LinkedEditingRangeParams() + { + TextDocument = new TextDocumentIdentifier() + { + Uri = document.CreateUri() + }, + Position = new Position() + { + Line = lineIndex, + Character = characterIndex + } + }; + + var result = await endpoint.GetTestAccessor().HandleRequestAsync(request, document, DisposalToken); + + if (spans.Length == 0) + { + Assert.Null(result); + return; + } + + Assert.NotNull(result); + Assert.Equal(LinkedEditingRangeHelper.WordPattern, result.WordPattern); + Assert.Equal(spans[0], result.Ranges[0].ToTextSpan(sourceText)); + Assert.Equal(spans[1], result.Ranges[1].ToTextSpan(sourceText)); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostTestBase.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostTestBase.cs new file mode 100644 index 00000000000..cc0800edf62 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostTestBase.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Text; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost; + +public abstract class CohostTestBase(ITestOutputHelper testOutputHelper) : WorkspaceTestBase(testOutputHelper) +{ + private IRemoteServiceProvider? _remoteServiceProvider; + + private protected IRemoteServiceProvider RemoteServiceProvider => _remoteServiceProvider.AssumeNotNull(); + + protected override Task InitializeAsync() + { + _remoteServiceProvider = new ShortCircuitingRemoteServiceProvider(TestOutputHelper); + + return base.InitializeAsync(); + } + + protected TextDocument CreateRazorDocument(string contents) + { + var projectFilePath = TestProjectData.SomeProject.FilePath; + var documentFilePath = TestProjectData.SomeProjectComponentFile1.FilePath; + var projectName = Path.GetFileNameWithoutExtension(projectFilePath); + var projectId = ProjectId.CreateNewId(debugName: projectName); + var documentId = DocumentId.CreateNewId(projectId, debugName: documentFilePath); + + var solution = Workspace.CurrentSolution.AddProject(ProjectInfo.Create( + projectId, + VersionStamp.Create(), + name: projectName, + assemblyName: projectName, + LanguageNames.CSharp, + documentFilePath)); + + solution = solution.AddAdditionalDocument( + documentId, + documentFilePath, + SourceText.From(contents), + filePath: documentFilePath); + + return solution.GetAdditionalDocument(documentId).AssumeNotNull(); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/InterceptingServiceBroker.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/InterceptingServiceBroker.cs new file mode 100644 index 00000000000..364f0effc00 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/InterceptingServiceBroker.cs @@ -0,0 +1,39 @@ +// 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.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Remote.Razor; +using Microsoft.ServiceHub.Framework; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost; + +internal class InterceptingServiceBroker(Solution solution) : IServiceBroker, IBrokeredServiceInterceptor +{ + public event EventHandler? AvailabilityChanged { add { } remove { } } + + public ValueTask GetPipeAsync(ServiceMoniker serviceMoniker, ServiceActivationOptions options = default, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public ValueTask GetProxyAsync(ServiceRpcDescriptor serviceDescriptor, ServiceActivationOptions options = default, CancellationToken cancellationToken = default) + where T : class + { + throw new NotImplementedException(); + } + + public ValueTask RunServiceAsync(Func implementation, CancellationToken cancellationToken) + { + return implementation(cancellationToken); + } + + public ValueTask RunServiceAsync(RazorPinnedSolutionInfoWrapper solutionInfo, Func> implementation, CancellationToken cancellationToken) + { + return implementation(solution); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/ShortCircuitingRemoteServiceProvider.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/ShortCircuitingRemoteServiceProvider.cs new file mode 100644 index 00000000000..e49bb65bbed --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/ShortCircuitingRemoteServiceProvider.cs @@ -0,0 +1,78 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Remote.Razor; +using Microsoft.ServiceHub.Framework; +using Nerdbank.Streams; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost; + +/// +/// An implementation of IRemoteServiceProvider that doesn't actually do anything remote, but rather directly calls service methods +/// +internal class ShortCircuitingRemoteServiceProvider(ITestOutputHelper testOutputHelper) : IRemoteServiceProvider +{ + private static readonly Dictionary s_factoryMap = BuildFactoryMap(); + + private readonly IServiceProvider _serviceProvider = new TestTraceSourceProvider(testOutputHelper); + + private static Dictionary BuildFactoryMap() + { + var result = new Dictionary(); + + foreach (var type in typeof(RazorServiceFactoryBase<>).Assembly.GetTypes()) + { + if (!type.IsAbstract && + typeof(IServiceHubServiceFactory).IsAssignableFrom(type)) + { + Assert.Equal(typeof(RazorServiceFactoryBase<>), type.BaseType.GetGenericTypeDefinition()); + + var genericType = type.BaseType.GetGenericArguments().FirstOrDefault(); + if (genericType != null) + { + // ServiceHub requires a parameterless constructor, so we can safely rely on it existing too + var factory = (IServiceHubServiceFactory)Activator.CreateInstance(type); + result.Add(genericType, factory); + } + } + } + + return result; + } + + public async ValueTask TryInvokeAsync( + Solution solution, + Func> invocation, + CancellationToken cancellationToken, + [CallerFilePath] string? callerFilePath = null, + [CallerMemberName] string? callerMemberName = null) + where TService : class + { + Assert.True(s_factoryMap.TryGetValue(typeof(TService), out var factory)); + + var testServiceBroker = new InterceptingServiceBroker(solution); + + // We don't ever use this stream, because we never really use ServiceHub, but going through its factory method means the + // remote services under test are using their full MEF composition etc. so we get excellent coverage. + var (stream, _) = FullDuplexStream.CreatePair(); + var service = (TService)await factory.CreateAsync(stream, _serviceProvider, serviceActivationOptions: default, testServiceBroker, authorizationServiceClient: default!); + + // This is never used, we short-circuited things by passing the solution direct to the InterceptingServiceBroker + var solutionInfo = new RazorPinnedSolutionInfoWrapper(); + + testOutputHelper.WriteLine($"Pretend OOP call for {typeof(TService).Name}, invocation: {Path.GetFileNameWithoutExtension(callerFilePath)}.{callerMemberName}"); + return await invocation(service, solutionInfo, cancellationToken); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/TestTraceSourceProvider.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/TestTraceSourceProvider.cs new file mode 100644 index 00000000000..b61b1a61d3c --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/TestTraceSourceProvider.cs @@ -0,0 +1,52 @@ +// 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.Diagnostics; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost; + +/// +/// An implementation of IServiceProvider that only provides a TraceSource, that writes to test output +/// +internal class TestTraceSourceProvider(ITestOutputHelper testOutputHelper) : IServiceProvider +{ + public object GetService(Type serviceType) + { + if (serviceType == typeof(TraceSource)) + { + return new TestOutputTraceSource(testOutputHelper); + } + + throw new NotImplementedException(); + } + + private class TestOutputTraceSource : TraceSource + { + public TestOutputTraceSource(ITestOutputHelper testOutputHelper) + : base("OOP", SourceLevels.All) + { + Listeners.Add(new TestOutputTraceListener(testOutputHelper)); + } + + private class TestOutputTraceListener(ITestOutputHelper testOutputHelper) : TraceListener + { + public override void Write(string message) + { + // ITestOutputHelper doesn't have a Write method, but all we lose is some extra ServiceHub details like log level + } + + public override void WriteLine(string message) + { + // Ignore some specific ServiceHub noise, since we're not using ServiceHub anyway + if (message.StartsWith("Added local RPC method") || message == "Listening started.") + { + return; + } + + testOutputHelper.WriteLine(message); + } + } + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Microsoft.VisualStudio.LanguageServices.Razor.Test.csproj b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Microsoft.VisualStudio.LanguageServices.Razor.Test.csproj index ace810bf8ac..264ae60a47e 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Microsoft.VisualStudio.LanguageServices.Razor.Test.csproj +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Microsoft.VisualStudio.LanguageServices.Razor.Test.csproj @@ -24,6 +24,7 @@ +