Skip to content

Commit 07e1382

Browse files
authored
Cohost Spell Check (#10825)
Needs dotnet/roslyn#74978 Fixes #10746 Part of #9519
2 parents 9678b91 + a3ee54d commit 07e1382

File tree

20 files changed

+583
-176
lines changed

20 files changed

+583
-176
lines changed

eng/targets/Services.props

+1
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@
3131
<ServiceHubService Include="Microsoft.VisualStudio.Razor.AutoInsert" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteAutoInsertService+Factory" />
3232
<ServiceHubService Include="Microsoft.VisualStudio.Razor.Formatting" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteFormattingService+Factory" />
3333
<ServiceHubService Include="Microsoft.VisualStudio.Razor.GoToImplementation" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteGoToImplementationService+Factory" />
34+
<ServiceHubService Include="Microsoft.VisualStudio.Razor.SpellCheck" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteSpellCheckService+Factory" />
3435
</ItemGroup>
3536
</Project>

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

+6-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
2828
using Microsoft.CodeAnalysis.Razor.Protocol;
2929
using Microsoft.CodeAnalysis.Razor.SemanticTokens;
30+
using Microsoft.CodeAnalysis.Razor.SpellCheck;
3031
using Microsoft.CodeAnalysis.Razor.Workspaces;
3132
using Microsoft.CommonLanguageServerProtocol.Framework;
3233
using Microsoft.Extensions.DependencyInjection;
@@ -161,10 +162,12 @@ public static void AddTextDocumentServices(this IServiceCollection services, Lan
161162
{
162163
services.AddHandlerWithCapabilities<TextDocumentTextPresentationEndpoint>();
163164
services.AddHandlerWithCapabilities<TextDocumentUriPresentationEndpoint>();
164-
}
165165

166-
services.AddHandlerWithCapabilities<DocumentSpellCheckEndpoint>();
167-
services.AddHandler<WorkspaceSpellCheckEndpoint>();
166+
services.AddSingleton<ISpellCheckService, SpellCheckService>();
167+
services.AddSingleton<ICSharpSpellCheckRangeProvider, LspCSharpSpellCheckRangeProvider>();
168+
services.AddHandlerWithCapabilities<DocumentSpellCheckEndpoint>();
169+
services.AddHandler<WorkspaceSpellCheckEndpoint>();
170+
}
168171

169172
services.AddHandlerWithCapabilities<DocumentDidChangeEndpoint>();
170173
services.AddHandler<DocumentDidCloseEndpoint>();

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/DocumentSpellCheckEndpoint.cs

+10-171
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,19 @@
22
// Licensed under the MIT license. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Collections.Generic;
65
using System.Threading;
76
using System.Threading.Tasks;
8-
using Microsoft.AspNetCore.Razor.Language;
9-
using Microsoft.AspNetCore.Razor.Language.Syntax;
107
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
11-
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
12-
using Microsoft.AspNetCore.Razor.PooledObjects;
13-
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
14-
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
15-
using Microsoft.CodeAnalysis.Razor.Protocol;
16-
using Microsoft.CodeAnalysis.Razor.Workspaces;
8+
using Microsoft.CodeAnalysis.Razor.SpellCheck;
179
using Microsoft.VisualStudio.LanguageServer.Protocol;
1810

1911
namespace Microsoft.AspNetCore.Razor.LanguageServer.SpellCheck;
2012

2113
[RazorLanguageServerEndpoint(VSInternalMethods.TextDocumentSpellCheckableRangesName)]
22-
internal sealed class DocumentSpellCheckEndpoint : IRazorRequestHandler<VSInternalDocumentSpellCheckableParams, VSInternalSpellCheckableRangeReport[]?>, ICapabilitiesProvider
14+
internal sealed class DocumentSpellCheckEndpoint(
15+
ISpellCheckService spellCheckService) : IRazorRequestHandler<VSInternalDocumentSpellCheckableParams, VSInternalSpellCheckableRangeReport[]?>, ICapabilitiesProvider
2316
{
24-
private readonly IDocumentMappingService _documentMappingService;
25-
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions;
26-
private readonly IClientConnection _clientConnection;
27-
28-
public DocumentSpellCheckEndpoint(
29-
IDocumentMappingService documentMappingService,
30-
LanguageServerFeatureOptions languageServerFeatureOptions,
31-
IClientConnection clientConnection)
32-
{
33-
_documentMappingService = documentMappingService ?? throw new ArgumentNullException(nameof(documentMappingService));
34-
_languageServerFeatureOptions = languageServerFeatureOptions ?? throw new ArgumentNullException(nameof(languageServerFeatureOptions));
35-
_clientConnection = clientConnection ?? throw new ArgumentNullException(nameof(clientConnection));
36-
}
17+
private readonly ISpellCheckService _spellCheckService = spellCheckService;
3718

3819
public bool MutatesSolutionState => false;
3920

@@ -43,14 +24,7 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V
4324
}
4425

4526
public TextDocumentIdentifier GetTextDocumentIdentifier(VSInternalDocumentSpellCheckableParams request)
46-
{
47-
if (request.TextDocument is null)
48-
{
49-
throw new ArgumentNullException(nameof(request.TextDocument));
50-
}
51-
52-
return request.TextDocument;
53-
}
27+
=> request.TextDocument;
5428

5529
public async Task<VSInternalSpellCheckableRangeReport[]?> HandleRequestAsync(VSInternalDocumentSpellCheckableParams request, RazorRequestContext requestContext, CancellationToken cancellationToken)
5630
{
@@ -60,150 +34,15 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(VSInternalDocumentSpellC
6034
return null;
6135
}
6236

63-
using var _ = ListPool<SpellCheckRange>.GetPooledObject(out var ranges);
37+
var data = await _spellCheckService.GetSpellCheckRangeTriplesAsync(documentContext, cancellationToken).ConfigureAwait(false);
6438

65-
await AddRazorSpellCheckRangesAsync(ranges, documentContext, cancellationToken).ConfigureAwait(false);
66-
67-
if (_languageServerFeatureOptions.SingleServerSupport)
68-
{
69-
await AddCSharpSpellCheckRangesAsync(ranges, documentContext, cancellationToken).ConfigureAwait(false);
70-
}
71-
72-
return new[]
73-
{
39+
return
40+
[
7441
new VSInternalSpellCheckableRangeReport
7542
{
76-
Ranges = ConvertSpellCheckRangesToIntTriples(ranges),
43+
Ranges = data,
7744
ResultId = Guid.NewGuid().ToString()
7845
}
79-
};
80-
}
81-
82-
private static async Task AddRazorSpellCheckRangesAsync(List<SpellCheckRange> ranges, DocumentContext documentContext, CancellationToken cancellationToken)
83-
{
84-
var tree = await documentContext.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
85-
86-
// We don't want to report spelling errors in script or style tags, so we avoid descending into them at all, which
87-
// means we don't need complicated logic, and it performs a bit better. We assume any C# in them will still be reported
88-
// by Roslyn.
89-
// 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
90-
// but it doesn't currently support it. When that support is added, we can remove all of this but the RazorCommentBlockSyntax
91-
// handling.
92-
foreach (var node in tree.Root.DescendantNodes(n => n is not MarkupElementSyntax { StartTag.Name.Content: "script" or "style" }))
93-
{
94-
if (node is RazorCommentBlockSyntax commentBlockSyntax)
95-
{
96-
ranges.Add(new((int)VSInternalSpellCheckableRangeKind.Comment, commentBlockSyntax.Comment.SpanStart, commentBlockSyntax.Comment.Span.Length));
97-
}
98-
else if (node is MarkupTextLiteralSyntax textLiteralSyntax)
99-
{
100-
// Attribute names are text literals, but we don't want to spell check them because either C# will,
101-
// whether they're component attributes based on property names, or they come from tag helper attribute
102-
// parameters as strings, or they're Html attributes which are not necessarily expected to be real words.
103-
if (node.Parent is MarkupTagHelperAttributeSyntax or
104-
MarkupAttributeBlockSyntax or
105-
MarkupMinimizedAttributeBlockSyntax or
106-
MarkupTagHelperDirectiveAttributeSyntax or
107-
MarkupMinimizedTagHelperAttributeSyntax or
108-
MarkupMinimizedTagHelperDirectiveAttributeSyntax or
109-
MarkupMiscAttributeContentSyntax)
110-
{
111-
continue;
112-
}
113-
114-
// Text literals appear everywhere in Razor to hold newlines and indentation, so its worth saving the tokens
115-
if (textLiteralSyntax.ContainsOnlyWhitespace())
116-
{
117-
continue;
118-
}
119-
120-
if (textLiteralSyntax.Span.Length == 0)
121-
{
122-
continue;
123-
}
124-
125-
ranges.Add(new((int)VSInternalSpellCheckableRangeKind.String, textLiteralSyntax.SpanStart, textLiteralSyntax.Span.Length));
126-
}
127-
}
128-
}
129-
130-
private async Task AddCSharpSpellCheckRangesAsync(List<SpellCheckRange> ranges, DocumentContext documentContext, CancellationToken cancellationToken)
131-
{
132-
var delegatedParams = new DelegatedSpellCheckParams(documentContext.GetTextDocumentIdentifierAndVersion());
133-
var delegatedResponse = await _clientConnection.SendRequestAsync<DelegatedSpellCheckParams, VSInternalSpellCheckableRangeReport[]?>(
134-
CustomMessageNames.RazorSpellCheckEndpoint,
135-
delegatedParams,
136-
cancellationToken).ConfigureAwait(false);
137-
138-
if (delegatedResponse is null)
139-
{
140-
return;
141-
}
142-
143-
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
144-
var csharpDocument = codeDocument.GetCSharpDocument();
145-
146-
foreach (var report in delegatedResponse)
147-
{
148-
if (report.Ranges is not { } csharpRanges)
149-
{
150-
continue;
151-
}
152-
153-
// Since we get C# tokens that have relative starts, we need to convert them back to absolute indexes
154-
// so we can sort them with the Razor tokens later
155-
var absoluteCSharpStartIndex = 0;
156-
for (var i = 0; i < csharpRanges.Length; i += 3)
157-
{
158-
var kind = csharpRanges[i];
159-
var start = csharpRanges[i + 1];
160-
var length = csharpRanges[i + 2];
161-
162-
absoluteCSharpStartIndex += start;
163-
164-
// 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
165-
// squiggles that go from C# into Razor/Html.
166-
if (_documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteCSharpStartIndex, out var _1, out var hostDocumentIndex) &&
167-
_documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteCSharpStartIndex + length, out var _2, out var _3))
168-
{
169-
ranges.Add(new(kind, hostDocumentIndex, length));
170-
}
171-
172-
absoluteCSharpStartIndex += length;
173-
}
174-
}
175-
}
176-
177-
private static int[] ConvertSpellCheckRangesToIntTriples(List<SpellCheckRange> ranges)
178-
{
179-
// Important to sort first, or the client will just ignore anything we say
180-
ranges.Sort(CompareSpellCheckRanges);
181-
182-
using var _ = ListPool<int>.GetPooledObject(out var data);
183-
data.SetCapacityIfLarger(ranges.Count * 3);
184-
185-
var lastAbsoluteEndIndex = 0;
186-
foreach (var range in ranges)
187-
{
188-
if (range.Length == 0)
189-
{
190-
continue;
191-
}
192-
193-
data.Add(range.Kind);
194-
data.Add(range.AbsoluteStartIndex - lastAbsoluteEndIndex);
195-
data.Add(range.Length);
196-
197-
lastAbsoluteEndIndex = range.AbsoluteStartIndex + range.Length;
198-
}
199-
200-
return data.ToArray();
201-
}
202-
203-
private record struct SpellCheckRange(int Kind, int AbsoluteStartIndex, int Length);
204-
205-
private static int CompareSpellCheckRanges(SpellCheckRange x, SpellCheckRange y)
206-
{
207-
return x.AbsoluteStartIndex.CompareTo(y.AbsoluteStartIndex);
46+
];
20847
}
20948
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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.Collections.Immutable;
7+
using System.Linq;
8+
using System.Text;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Microsoft.AspNetCore.Razor.Language;
12+
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
13+
using Microsoft.AspNetCore.Razor.PooledObjects;
14+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
15+
using Microsoft.CodeAnalysis.Razor.Protocol;
16+
using Microsoft.CodeAnalysis.Razor.SpellCheck;
17+
using Microsoft.CodeAnalysis.Razor.Workspaces;
18+
using Microsoft.VisualStudio.LanguageServer.Protocol;
19+
20+
namespace Microsoft.AspNetCore.Razor.LanguageServer.SpellCheck;
21+
22+
internal sealed class LspCSharpSpellCheckRangeProvider(
23+
LanguageServerFeatureOptions languageServerFeatureOptions,
24+
IClientConnection clientConnection) : ICSharpSpellCheckRangeProvider
25+
{
26+
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
27+
private readonly IClientConnection _clientConnection = clientConnection;
28+
29+
public async Task<ImmutableArray<SpellCheckRange>> GetCSharpSpellCheckRangesAsync(DocumentContext documentContext, CancellationToken cancellationToken)
30+
{
31+
if (!_languageServerFeatureOptions.SingleServerSupport)
32+
{
33+
return [];
34+
}
35+
36+
var delegatedParams = new DelegatedSpellCheckParams(documentContext.GetTextDocumentIdentifierAndVersion());
37+
var delegatedResponse = await _clientConnection.SendRequestAsync<DelegatedSpellCheckParams, VSInternalSpellCheckableRangeReport[]?>(
38+
CustomMessageNames.RazorSpellCheckEndpoint,
39+
delegatedParams,
40+
cancellationToken).ConfigureAwait(false);
41+
42+
if (delegatedResponse is not [_, ..] response)
43+
{
44+
return [];
45+
}
46+
47+
// Most common case is we'll get one report back from Roslyn, so we'll use that as the initial capacity.
48+
var initialCapacity = response[0].Ranges?.Length ?? 4;
49+
50+
using var ranges = new PooledArrayBuilder<SpellCheckRange>(initialCapacity);
51+
foreach (var report in delegatedResponse)
52+
{
53+
if (report.Ranges is not { } csharpRanges)
54+
{
55+
continue;
56+
}
57+
58+
// Since we get C# tokens that have relative starts, we need to convert them back to absolute indexes
59+
// so we can sort them with the Razor tokens later
60+
var absoluteCSharpStartIndex = 0;
61+
for (var i = 0; i < csharpRanges.Length; i += 3)
62+
{
63+
var kind = csharpRanges[i];
64+
var start = csharpRanges[i + 1];
65+
var length = csharpRanges[i + 2];
66+
67+
absoluteCSharpStartIndex += start;
68+
69+
ranges.Add(new(kind, absoluteCSharpStartIndex, length));
70+
71+
absoluteCSharpStartIndex += length;
72+
}
73+
}
74+
75+
return ranges.DrainToImmutable();
76+
}
77+
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RazorSyntaxNodeExtensions.cs

+12
Original file line numberDiff line numberDiff line change
@@ -308,4 +308,16 @@ static bool IsCSharpCodeBlockSyntax(SyntaxNode node)
308308
return node is CSharpCodeBlockSyntax;
309309
}
310310
}
311+
312+
public static bool IsAnyAttributeSyntax(this SyntaxNode node)
313+
{
314+
return node is
315+
MarkupAttributeBlockSyntax or
316+
MarkupMinimizedAttributeBlockSyntax or
317+
MarkupTagHelperAttributeSyntax or
318+
MarkupMinimizedTagHelperAttributeSyntax or
319+
MarkupTagHelperDirectiveAttributeSyntax or
320+
MarkupMinimizedTagHelperDirectiveAttributeSyntax or
321+
MarkupMiscAttributeContentSyntax;
322+
}
311323
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
7+
8+
namespace Microsoft.CodeAnalysis.Razor.Remote;
9+
10+
internal interface IRemoteSpellCheckService
11+
{
12+
ValueTask<int[]> GetSpellCheckRangeTriplesAsync(
13+
RazorPinnedSolutionInfoWrapper solutionInfo,
14+
DocumentId razorDocumentId,
15+
CancellationToken cancellationToken);
16+
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ internal static class RazorServices
2323
(typeof(IRemoteDocumentHighlightService), null),
2424
(typeof(IRemoteAutoInsertService), null),
2525
(typeof(IRemoteFormattingService), null),
26+
(typeof(IRemoteSpellCheckService), null),
2627
];
2728

2829
// Internal for testing
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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.Collections.Immutable;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
8+
9+
namespace Microsoft.CodeAnalysis.Razor.SpellCheck;
10+
11+
internal interface ICSharpSpellCheckRangeProvider
12+
{
13+
Task<ImmutableArray<SpellCheckRange>> GetCSharpSpellCheckRangesAsync(DocumentContext documentContext, CancellationToken cancellationToken);
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
7+
8+
namespace Microsoft.CodeAnalysis.Razor.SpellCheck;
9+
10+
internal interface ISpellCheckService
11+
{
12+
Task<int[]> GetSpellCheckRangeTriplesAsync(DocumentContext documentContext, CancellationToken cancellationToken);
13+
}

0 commit comments

Comments
 (0)