+ 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.
+
+ [|div|]>
+
+ 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.
+ [|div|]>
+
+
+ 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 @@
+