Skip to content

Commit 0b5b042

Browse files
authored
Merge pull request #8851 from davidwengier/SpellChecking
Add spell checking endpoints
2 parents 98b5141 + 7178f51 commit 0b5b042

File tree

10 files changed

+494
-2
lines changed

10 files changed

+494
-2
lines changed

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer.Common/RazorLanguageServerCustomMessageTargets.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ internal static class RazorLanguageServerCustomMessageTargets
1212
public const string RazorSemanticTokensRefreshEndpoint = "razor/semanticTokensRefresh";
1313
public const string RazorTextPresentationEndpoint = "razor/textPresentation";
1414
public const string RazorUriPresentationEndpoint = "razor/uriPresentation";
15+
public const string RazorSpellCheckEndpoint = "razor/spellCheck";
1516

1617
// Cross platform
1718
public const string RazorUpdateCSharpBufferEndpoint = "razor/updateCSharpBuffer";

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer.Protocol/DelegatedTypes.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
namespace Microsoft.AspNetCore.Razor.LanguageServer.Protocol;
1313

14+
internal record DelegatedSpellCheckParams(
15+
VersionedTextDocumentIdentifier HostDocument);
16+
1417
internal record DelegatedDiagnosticParams(
1518
VersionedTextDocumentIdentifier HostDocument,
1619
Guid CorrelationId);

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System;
55
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions;
6-
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor;
76
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
87
using Microsoft.AspNetCore.Razor.LanguageServer.Completion;
98
using Microsoft.AspNetCore.Razor.LanguageServer.Completion.Delegation;
@@ -16,6 +15,7 @@
1615
using Microsoft.AspNetCore.Razor.LanguageServer.InlineCompletion;
1716
using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem;
1817
using Microsoft.AspNetCore.Razor.LanguageServer.Semantic;
18+
using Microsoft.AspNetCore.Razor.LanguageServer.SpellCheck;
1919
using Microsoft.AspNetCore.Razor.LanguageServer.Tooltip;
2020
using Microsoft.CodeAnalysis.Razor.Completion;
2121
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
@@ -155,6 +155,9 @@ public static void AddTextDocumentServices(this IServiceCollection services)
155155
services.AddRegisteringHandler<TextDocumentTextPresentationEndpoint>();
156156
services.AddRegisteringHandler<TextDocumentUriPresentationEndpoint>();
157157

158+
services.AddRegisteringHandler<DocumentSpellCheckEndpoint>();
159+
services.AddHandler<WorkspaceSpellCheckEndpoint>();
160+
158161
services.AddRegisteringHandler<DocumentDidChangeEndpoint>();
159162
services.AddHandler<DocumentDidCloseEndpoint>();
160163
services.AddHandler<DocumentDidOpenEndpoint>();
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT license. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Razor.Language;
10+
using Microsoft.AspNetCore.Razor.Language.Syntax;
11+
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
12+
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
13+
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
14+
using Microsoft.AspNetCore.Razor.LanguageServer.Protocol;
15+
using Microsoft.AspNetCore.Razor.PooledObjects;
16+
using Microsoft.CodeAnalysis.Razor.Workspaces;
17+
using Microsoft.CommonLanguageServerProtocol.Framework;
18+
using Microsoft.VisualStudio.LanguageServer.Protocol;
19+
20+
namespace Microsoft.AspNetCore.Razor.LanguageServer.SpellCheck;
21+
22+
[LanguageServerEndpoint(VSInternalMethods.TextDocumentSpellCheckableRangesName)]
23+
internal sealed class DocumentSpellCheckEndpoint : IRazorRequestHandler<VSInternalDocumentSpellCheckableParams, VSInternalSpellCheckableRangeReport[]>, IRegistrationExtension
24+
{
25+
private readonly IRazorDocumentMappingService _documentMappingService;
26+
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions;
27+
private readonly ClientNotifierServiceBase _languageServer;
28+
29+
public DocumentSpellCheckEndpoint(
30+
IRazorDocumentMappingService documentMappingService,
31+
LanguageServerFeatureOptions languageServerFeatureOptions,
32+
ClientNotifierServiceBase languageServer)
33+
{
34+
_documentMappingService = documentMappingService ?? throw new ArgumentNullException(nameof(documentMappingService));
35+
_languageServerFeatureOptions = languageServerFeatureOptions ?? throw new ArgumentNullException(nameof(languageServerFeatureOptions));
36+
_languageServer = languageServer ?? throw new ArgumentNullException(nameof(languageServer));
37+
}
38+
39+
public bool MutatesSolutionState => false;
40+
41+
public RegistrationExtensionResult GetRegistration(VSInternalClientCapabilities clientCapabilities)
42+
{
43+
const string ServerCapability = "_vs_spellCheckingProvider";
44+
45+
return new RegistrationExtensionResult(ServerCapability, true);
46+
}
47+
48+
public TextDocumentIdentifier GetTextDocumentIdentifier(VSInternalDocumentSpellCheckableParams request)
49+
{
50+
if (request.TextDocument is null)
51+
{
52+
throw new ArgumentNullException(nameof(request.TextDocument));
53+
}
54+
55+
return request.TextDocument;
56+
}
57+
58+
public async Task<VSInternalSpellCheckableRangeReport[]> HandleRequestAsync(VSInternalDocumentSpellCheckableParams request, RazorRequestContext requestContext, CancellationToken cancellationToken)
59+
{
60+
var documentContext = requestContext.GetRequiredDocumentContext();
61+
62+
using var _ = ListPool<SpellCheckRange>.GetPooledObject(out var ranges);
63+
64+
await AddRazorSpellCheckRangesAsync(ranges, documentContext, cancellationToken).ConfigureAwait(false);
65+
66+
if (_languageServerFeatureOptions.SingleServerSupport)
67+
{
68+
await AddCSharpSpellCheckRangesAsync(ranges, documentContext, cancellationToken).ConfigureAwait(false);
69+
}
70+
71+
return new[]
72+
{
73+
new VSInternalSpellCheckableRangeReport
74+
{
75+
Ranges = ConvertSpellCheckRangesToIntTriples(ranges),
76+
ResultId = Guid.NewGuid().ToString()
77+
}
78+
};
79+
}
80+
81+
private static async Task AddRazorSpellCheckRangesAsync(List<SpellCheckRange> ranges, VersionedDocumentContext documentContext, CancellationToken cancellationToken)
82+
{
83+
var tree = await documentContext.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
84+
85+
// We don't want to report spelling errors in script or style tags, so we avoid descending into them at all, which
86+
// means we don't need complicated logic, and it performs a bit better. We assume any C# in them will still be reported
87+
// by Roslyn.
88+
// In an ideal world we wouldn't need this logic at all, as we would defer to the Html LSP server to provide spell checking
89+
// but it doesn't currently support it. When that support is added, we can remove all of this but the RazorCommentBlockSyntax
90+
// handling.
91+
foreach (var node in tree.Root.DescendantNodes(n => n is not MarkupElementSyntax { StartTag.Name.Content: "script" or "style" }))
92+
{
93+
if (node is RazorCommentBlockSyntax commentBlockSyntax)
94+
{
95+
ranges.Add(new((int)VSInternalSpellCheckableRangeKind.Comment, commentBlockSyntax.Comment.SpanStart, commentBlockSyntax.Comment.Span.Length));
96+
}
97+
else if (node is MarkupTextLiteralSyntax textLiteralSyntax)
98+
{
99+
// Attribute names are text literals, but we don't want to spell check them because either C# will,
100+
// whether they're component attributes based on property names, or they come from tag helper attribute
101+
// parameters as strings, or they're Html attributes which are not necessarily expected to be real words.
102+
if (node.Parent is MarkupTagHelperAttributeSyntax or MarkupAttributeBlockSyntax)
103+
{
104+
continue;
105+
}
106+
107+
// Text literals appear everywhere in Razor to hold newlines and indentation, so its worth saving the tokens
108+
if (textLiteralSyntax.ContainsOnlyWhitespace())
109+
{
110+
continue;
111+
}
112+
113+
ranges.Add(new((int)VSInternalSpellCheckableRangeKind.String, textLiteralSyntax.SpanStart, textLiteralSyntax.Span.Length));
114+
}
115+
}
116+
}
117+
118+
private async Task AddCSharpSpellCheckRangesAsync(List<SpellCheckRange> ranges, VersionedDocumentContext documentContext, CancellationToken cancellationToken)
119+
{
120+
var delegatedParams = new DelegatedSpellCheckParams(documentContext.Identifier);
121+
var delegatedResponse = await _languageServer.SendRequestAsync<DelegatedSpellCheckParams, VSInternalSpellCheckableRangeReport[]?>(
122+
RazorLanguageServerCustomMessageTargets.RazorSpellCheckEndpoint,
123+
delegatedParams,
124+
cancellationToken).ConfigureAwait(false);
125+
126+
if (delegatedResponse is null)
127+
{
128+
return;
129+
}
130+
131+
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
132+
var csharpDocument = codeDocument.GetCSharpDocument();
133+
134+
foreach (var report in delegatedResponse)
135+
{
136+
if (report.Ranges is not { } csharpRanges)
137+
{
138+
continue;
139+
}
140+
141+
// Since we get C# tokens that have relative starts, we need to convert them back to absolute indexes
142+
// so we can sort them with the Razor tokens later
143+
var absoluteCSharpStartIndex = 0;
144+
for (var i = 0; i < csharpRanges.Length; i += 3)
145+
{
146+
var kind = csharpRanges[i];
147+
var start = csharpRanges[i + 1];
148+
var length = csharpRanges[i + 2];
149+
150+
absoluteCSharpStartIndex += start;
151+
152+
// We need to map the start index to produce results, and we validate that we can map the end index so we don't have
153+
// squiggles that go from C# into Razor/Html.
154+
if (_documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteCSharpStartIndex, out var _1, out var hostDocumentIndex) &&
155+
_documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteCSharpStartIndex + length, out var _2, out var _3))
156+
{
157+
ranges.Add(new(kind, hostDocumentIndex, length));
158+
}
159+
160+
absoluteCSharpStartIndex += length;
161+
}
162+
}
163+
}
164+
165+
private static int[] ConvertSpellCheckRangesToIntTriples(List<SpellCheckRange> ranges)
166+
{
167+
// Important to sort first, or the client will just ignore anything we say
168+
ranges.Sort(AbsoluteStartIndexComparer.Instance);
169+
170+
using var _ = ListPool<int>.GetPooledObject(out var data);
171+
data.SetCapacityIfLarger(ranges.Count * 3);
172+
173+
var lastAbsoluteEndIndex = 0;
174+
foreach (var range in ranges)
175+
{
176+
if (range.Length == 0)
177+
{
178+
continue;
179+
}
180+
181+
data.Add(range.Kind);
182+
data.Add(range.AbsoluteStartIndex - lastAbsoluteEndIndex);
183+
data.Add(range.Length);
184+
185+
lastAbsoluteEndIndex = range.AbsoluteStartIndex + range.Length;
186+
}
187+
188+
return data.ToArray();
189+
}
190+
191+
private sealed record SpellCheckRange(int Kind, int AbsoluteStartIndex, int Length);
192+
193+
private sealed class AbsoluteStartIndexComparer : IComparer<SpellCheckRange>
194+
{
195+
public static readonly AbsoluteStartIndexComparer Instance = new();
196+
197+
public int Compare(SpellCheckRange? x, SpellCheckRange? y)
198+
{
199+
if (x is null || y is null)
200+
{
201+
Debug.Fail("There shouldn't be a null in the list of spell check ranges.");
202+
203+
return 0;
204+
}
205+
206+
return x.AbsoluteStartIndex.CompareTo(y.AbsoluteStartIndex);
207+
}
208+
}
209+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT license. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
8+
using Microsoft.CommonLanguageServerProtocol.Framework;
9+
using Microsoft.VisualStudio.LanguageServer.Protocol;
10+
11+
namespace Microsoft.AspNetCore.Razor.LanguageServer.SpellCheck;
12+
13+
[LanguageServerEndpoint(VSInternalMethods.WorkspaceSpellCheckableRangesName)]
14+
internal sealed class WorkspaceSpellCheckEndpoint : IRazorDocumentlessRequestHandler<VSInternalWorkspaceSpellCheckableParams, VSInternalWorkspaceSpellCheckableReport[]>
15+
{
16+
public bool MutatesSolutionState => false;
17+
18+
// Razor files generally don't do anything at the workspace level, so continuing that tradition for spell checking
19+
20+
public Task<VSInternalWorkspaceSpellCheckableReport[]> HandleRequestAsync(VSInternalWorkspaceSpellCheckableParams request, RazorRequestContext context, CancellationToken cancellationToken)
21+
=> Task.FromResult(Array.Empty<VSInternalWorkspaceSpellCheckableReport>());
22+
}

src/Razor/src/Microsoft.VisualStudio.LanguageServerClient.Razor/DefaultRazorLanguageServerCustomMessageTarget.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,44 @@ public override Task<ImplementationResult> ImplementationAsync(DelegatedPosition
11871187
return response.Response;
11881188
}
11891189

1190+
public override async Task<VSInternalSpellCheckableRangeReport[]> SpellCheckAsync(DelegatedSpellCheckParams request, CancellationToken cancellationToken)
1191+
{
1192+
var hostDocument = request.HostDocument;
1193+
var (synchronized, virtualDocument) = await _documentSynchronizer.TrySynchronizeVirtualDocumentAsync<CSharpVirtualDocumentSnapshot>(
1194+
hostDocument.Version,
1195+
hostDocument.Uri,
1196+
cancellationToken).ConfigureAwait(false);
1197+
if (!synchronized)
1198+
{
1199+
return Array.Empty<VSInternalSpellCheckableRangeReport>();
1200+
}
1201+
1202+
var spellCheckParams = new VSInternalDocumentSpellCheckableParams
1203+
{
1204+
TextDocument = new TextDocumentIdentifier
1205+
{
1206+
Uri = virtualDocument.Uri,
1207+
},
1208+
};
1209+
1210+
var response = await _requestInvoker.ReinvokeRequestOnServerAsync<VSInternalDocumentSpellCheckableParams, VSInternalSpellCheckableRangeReport[]>(
1211+
virtualDocument.Snapshot.TextBuffer,
1212+
VSInternalMethods.TextDocumentSpellCheckableRangesName,
1213+
RazorLSPConstants.RazorCSharpLanguageServerName,
1214+
SupportsSpellCheck,
1215+
spellCheckParams,
1216+
cancellationToken).ConfigureAwait(false);
1217+
1218+
return response?.Response ?? Array.Empty<VSInternalSpellCheckableRangeReport>();
1219+
}
1220+
1221+
private static bool SupportsSpellCheck(JToken token)
1222+
{
1223+
var serverCapabilities = token.ToObject<VSInternalServerCapabilities>();
1224+
1225+
return serverCapabilities?.SpellCheckingProvider ?? false;
1226+
}
1227+
11901228
private async Task<TResult?> DelegateTextDocumentPositionRequestAsync<TResult>(DelegatedPositionParams request, string methodName, CancellationToken cancellationToken)
11911229
{
11921230
var delegationDetails = await GetProjectedRequestDetailsAsync(request, cancellationToken).ConfigureAwait(false);

src/Razor/src/Microsoft.VisualStudio.LanguageServerClient.Razor/RazorLanguageServerCustomMessageTarget.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,7 @@ internal abstract class RazorLanguageServerCustomMessageTarget
126126

127127
[JsonRpcMethod(RazorLanguageServerCustomMessageTargets.RazorReferencesEndpointName, UseSingleObjectParameterDeserialization = true)]
128128
public abstract Task<VSInternalReferenceItem[]?> ReferencesAsync(DelegatedPositionParams request, CancellationToken cancellationToken);
129+
130+
[JsonRpcMethod(RazorLanguageServerCustomMessageTargets.RazorSpellCheckEndpoint, UseSingleObjectParameterDeserialization = true)]
131+
public abstract Task<VSInternalSpellCheckableRangeReport[]> SpellCheckAsync(DelegatedSpellCheckParams request, CancellationToken cancellationToken);
129132
}

src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/SingleServerDelegatingEndpointTestBase.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,33 @@ public async override Task<TResponse> SendRequestAsync<TParams, TResponse>(strin
134134
RazorLanguageServerCustomMessageTargets.RazorResolveCodeActionsEndpoint => await HandleResolveCodeActionsAsync(@params),
135135
RazorLanguageServerCustomMessageTargets.RazorPullDiagnosticEndpointName => await HandlePullDiagnosticsAsync(@params),
136136
RazorLanguageServerCustomMessageTargets.RazorFoldingRangeEndpoint => await HandleFoldingRangeAsync(),
137+
RazorLanguageServerCustomMessageTargets.RazorSpellCheckEndpoint => await HandleSpellCheckAsync(@params),
137138
_ => throw new NotImplementedException($"I don't know how to handle the '{method}' method.")
138139
};
139140

140141
return (TResponse)result;
141142
}
142143

144+
private async Task<VSInternalSpellCheckableRangeReport[]> HandleSpellCheckAsync<TParams>(TParams @params)
145+
{
146+
Assert.IsType<DelegatedSpellCheckParams>(@params);
147+
148+
var delegatedRequest = new VSInternalDocumentSpellCheckableParams
149+
{
150+
TextDocument = new TextDocumentIdentifier
151+
{
152+
Uri = _csharpDocumentUri,
153+
},
154+
};
155+
156+
var result = await _csharpServer.ExecuteRequestAsync<VSInternalDocumentSpellCheckableParams, VSInternalSpellCheckableRangeReport[]>(
157+
VSInternalMethods.TextDocumentSpellCheckableRangesName,
158+
delegatedRequest,
159+
_cancellationToken);
160+
161+
return result;
162+
}
163+
143164
private async Task<RazorPullDiagnosticResponse> HandlePullDiagnosticsAsync<TParams>(TParams @params)
144165
{
145166
Assert.IsType<DelegatedDiagnosticParams>(@params);

0 commit comments

Comments
 (0)