Skip to content

Commit e5417bd

Browse files
Copilotdavidwengier
andcommitted
Add initial implementation of WrapWithTag cohosting infrastructure
Co-authored-by: davidwengier <[email protected]>
1 parent 95a4414 commit e5417bd

File tree

4 files changed

+227
-0
lines changed

4 files changed

+227
-0
lines changed

eng/targets/Services.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,6 @@
3939
<ServiceHubService Include="Microsoft.VisualStudio.Razor.FindAllReferences" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteFindAllReferencesService+Factory" />
4040
<ServiceHubService Include="Microsoft.VisualStudio.Razor.InlineCompletion" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteInlineCompletionService+Factory" />
4141
<ServiceHubService Include="Microsoft.VisualStudio.Razor.DebugInfo" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteDebugInfoService+Factory" />
42+
<ServiceHubService Include="Microsoft.VisualStudio.Razor.WrapWithTag" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteWrapWithTagService+Factory" />
4243
</ItemGroup>
4344
</Project>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
7+
using Microsoft.CodeAnalysis.Razor.Protocol;
8+
9+
namespace Microsoft.CodeAnalysis.Razor.Remote;
10+
11+
internal interface IRemoteWrapWithTagService
12+
{
13+
ValueTask<RemoteResponse<bool>> IsValidWrapWithTagLocationAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, LspRange range, CancellationToken cancellationToken);
14+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore.Razor.Language;
7+
using Microsoft.AspNetCore.Razor.Language.Syntax;
8+
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
9+
using Microsoft.CodeAnalysis.Razor.Protocol;
10+
using Microsoft.CodeAnalysis.Razor.Remote;
11+
using Microsoft.CodeAnalysis.Razor.Workspaces;
12+
using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem;
13+
using Microsoft.CodeAnalysis.Text;
14+
using Response = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse<bool>;
15+
16+
namespace Microsoft.CodeAnalysis.Remote.Razor;
17+
18+
internal sealed partial class RemoteWrapWithTagService(in ServiceArgs args) : RazorDocumentServiceBase(in args), IRemoteWrapWithTagService
19+
{
20+
internal sealed class Factory : FactoryBase<IRemoteWrapWithTagService>
21+
{
22+
protected override IRemoteWrapWithTagService CreateService(in ServiceArgs args)
23+
=> new RemoteWrapWithTagService(in args);
24+
}
25+
26+
public ValueTask<Response> IsValidWrapWithTagLocationAsync(
27+
RazorPinnedSolutionInfoWrapper solutionInfo,
28+
DocumentId razorDocumentId,
29+
LspRange range,
30+
CancellationToken cancellationToken)
31+
=> RunServiceAsync(
32+
solutionInfo,
33+
razorDocumentId,
34+
context => IsValidWrapWithTagLocationAsync(context, range, cancellationToken),
35+
cancellationToken);
36+
37+
private async ValueTask<Response> IsValidWrapWithTagLocationAsync(
38+
RemoteDocumentContext context,
39+
LspRange range,
40+
CancellationToken cancellationToken)
41+
{
42+
var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
43+
var sourceText = codeDocument.Source.Text;
44+
45+
if (range?.Start is not { } start ||
46+
!sourceText.TryGetAbsoluteIndex(start, out var hostDocumentIndex))
47+
{
48+
return Response.NoFurtherHandling;
49+
}
50+
51+
// First thing we do is make sure we start at a non-whitespace character. This is important because in some
52+
// situations the whitespace can be technically C#, but move one character to the right and it's HTML. eg
53+
//
54+
// @if (true) {
55+
// | <p></p>
56+
// }
57+
//
58+
// Limiting this to only whitespace on the same line, as it's not clear what user expectation would be otherwise.
59+
var requestSpan = sourceText.GetTextSpan(range);
60+
if (sourceText.TryGetFirstNonWhitespaceOffset(requestSpan, out var offset, out var newLineCount) &&
61+
newLineCount == 0)
62+
{
63+
hostDocumentIndex += offset;
64+
}
65+
66+
// Since we're at the start of the selection, lets prefer the language to the right of the cursor if possible.
67+
// That way with the following situation:
68+
//
69+
// @if (true) {
70+
// |<p></p>
71+
// }
72+
//
73+
// Instead of C#, which certainly would be expected to go in an if statement, we'll see HTML, which obviously
74+
// is the better choice for this operation.
75+
var languageKind = codeDocument.GetLanguageKind(hostDocumentIndex, rightAssociative: true);
76+
77+
// However, reverse scenario is possible as well, when we have
78+
// <div>
79+
// |@if (true) {}
80+
// <p></p>
81+
// </div>
82+
// in which case right-associative GetLanguageKind will return Razor and left-associative will return HTML
83+
// We should hand that case as well, see https://github.com/dotnet/razor/issues/10819
84+
if (languageKind is RazorLanguageKind.Razor)
85+
{
86+
languageKind = codeDocument.GetLanguageKind(hostDocumentIndex, rightAssociative: false);
87+
}
88+
89+
if (languageKind is not RazorLanguageKind.Html)
90+
{
91+
// In general, we don't support C# for obvious reasons, but we can support implicit expressions. ie
92+
//
93+
// <p>@curr$$entCount</p>
94+
//
95+
// We can expand the range to encompass the whole implicit expression, and then it will wrap as expected.
96+
// Similarly if they have selected the implicit expression, then we can continue. ie
97+
//
98+
// <p>[|@currentCount|]</p>
99+
100+
var tree = await context.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
101+
var node = tree.Root.FindNode(requestSpan, includeWhitespace: false, getInnermostNodeForTie: true);
102+
if (node?.FirstAncestorOrSelf<CSharpImplicitExpressionSyntax>() is { Parent: CSharpCodeBlockSyntax codeBlock } &&
103+
(requestSpan == codeBlock.Span || requestSpan.Length == 0))
104+
{
105+
// Pretend we're in Html so the rest of the logic can continue
106+
languageKind = RazorLanguageKind.Html;
107+
}
108+
}
109+
110+
if (languageKind is not RazorLanguageKind.Html)
111+
{
112+
return Response.NoFurtherHandling;
113+
}
114+
115+
return Response.Results(true);
116+
}
117+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Immutable;
5+
using System.Composition;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost;
10+
using Microsoft.CodeAnalysis.Razor.Formatting;
11+
using Microsoft.CodeAnalysis.Razor.Protocol;
12+
using Microsoft.CodeAnalysis.Razor.Remote;
13+
using Microsoft.CodeAnalysis.Razor.Workspaces;
14+
using Microsoft.CodeAnalysis.Text;
15+
using Microsoft.VisualStudio.Razor.LanguageClient.WrapWithTag;
16+
17+
namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
18+
19+
#pragma warning disable RS0030 // Do not use banned APIs
20+
[Shared]
21+
[CohostEndpoint(LanguageServerConstants.RazorWrapWithTagEndpoint)]
22+
[Export(typeof(IDynamicRegistrationProvider))]
23+
[ExportCohostStatelessLspService(typeof(CohostWrapWithTagEndpoint))]
24+
[method: ImportingConstructor]
25+
#pragma warning restore RS0030 // Do not use banned APIs
26+
internal sealed class CohostWrapWithTagEndpoint(
27+
IRemoteServiceInvoker remoteServiceInvoker,
28+
IFilePathService filePathService,
29+
IHtmlRequestInvoker requestInvoker)
30+
: AbstractRazorCohostDocumentRequestHandler<VSInternalWrapWithTagParams, VSInternalWrapWithTagResponse?>, IDynamicRegistrationProvider
31+
{
32+
private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker;
33+
private readonly IFilePathService _filePathService = filePathService;
34+
private readonly IHtmlRequestInvoker _requestInvoker = requestInvoker;
35+
36+
protected override bool MutatesSolutionState => false;
37+
38+
protected override bool RequiresLSPSolution => true;
39+
40+
public ImmutableArray<Registration> GetRegistrations(VSInternalClientCapabilities clientCapabilities, RazorCohostRequestContext requestContext)
41+
{
42+
if (clientCapabilities.SupportsVisualStudioExtensions)
43+
{
44+
return [new Registration
45+
{
46+
Method = LanguageServerConstants.RazorWrapWithTagEndpoint,
47+
RegisterOptions = new TextDocumentRegistrationOptions()
48+
}];
49+
}
50+
51+
return [];
52+
}
53+
54+
protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(VSInternalWrapWithTagParams request)
55+
=> request.TextDocument.ToRazorTextDocumentIdentifier();
56+
57+
protected override Task<VSInternalWrapWithTagResponse?> HandleRequestAsync(VSInternalWrapWithTagParams request, RazorCohostRequestContext context, CancellationToken cancellationToken)
58+
=> HandleRequestAsync(request, context.TextDocument.AssumeNotNull(), cancellationToken);
59+
60+
private async Task<VSInternalWrapWithTagResponse?> HandleRequestAsync(VSInternalWrapWithTagParams request, TextDocument razorDocument, CancellationToken cancellationToken)
61+
{
62+
// First, check if the position is valid for wrap with tag operation through the remote service
63+
var isValidLocation = await _remoteServiceInvoker.TryInvokeAsync<IRemoteWrapWithTagService, RemoteResponse<bool>>(
64+
razorDocument.Project.Solution,
65+
(service, solutionInfo, cancellationToken) => service.IsValidWrapWithTagLocationAsync(solutionInfo, razorDocument.Id, request.Range, cancellationToken),
66+
cancellationToken).ConfigureAwait(false);
67+
68+
// If the remote service says it's not a valid location or we should stop handling, return null
69+
if (!isValidLocation.Result || isValidLocation.StopHandling)
70+
{
71+
return null;
72+
}
73+
74+
// The location is valid, so delegate to the HTML server
75+
var htmlResponse = await _requestInvoker.MakeHtmlLspRequestAsync<VSInternalWrapWithTagParams, VSInternalWrapWithTagResponse>(
76+
razorDocument,
77+
LanguageServerConstants.RazorWrapWithTagEndpoint,
78+
request,
79+
cancellationToken).ConfigureAwait(false);
80+
81+
// TODO: Consider if we need to fix HTML text edits in the cohost scenario
82+
// The original language server implementation calls FormattingUtilities.FixHtmlTextEdits
83+
// but this might not be necessary in the cohost context
84+
85+
return htmlResponse;
86+
}
87+
88+
internal TestAccessor GetTestAccessor() => new(this);
89+
90+
internal readonly struct TestAccessor(CohostWrapWithTagEndpoint instance)
91+
{
92+
public Task<VSInternalWrapWithTagResponse?> HandleRequestAsync(VSInternalWrapWithTagParams request, TextDocument razorDocument, CancellationToken cancellationToken)
93+
=> instance.HandleRequestAsync(request, razorDocument, cancellationToken);
94+
}
95+
}

0 commit comments

Comments
 (0)