Skip to content

Commit

Permalink
Cohost Spell Check (#10825)
Browse files Browse the repository at this point in the history
Needs dotnet/roslyn#74978
Fixes #10746
Part of #9519
  • Loading branch information
davidwengier committed Sep 6, 2024
2 parents 9678b91 + a3ee54d commit 07e1382
Show file tree
Hide file tree
Showing 20 changed files with 583 additions and 176 deletions.
1 change: 1 addition & 0 deletions eng/targets/Services.props
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@
<ServiceHubService Include="Microsoft.VisualStudio.Razor.AutoInsert" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteAutoInsertService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.Formatting" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteFormattingService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.GoToImplementation" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteGoToImplementationService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.SpellCheck" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteSpellCheckService+Factory" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.SemanticTokens;
using Microsoft.CodeAnalysis.Razor.SpellCheck;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -161,10 +162,12 @@ public static void AddTextDocumentServices(this IServiceCollection services, Lan
{
services.AddHandlerWithCapabilities<TextDocumentTextPresentationEndpoint>();
services.AddHandlerWithCapabilities<TextDocumentUriPresentationEndpoint>();
}

services.AddHandlerWithCapabilities<DocumentSpellCheckEndpoint>();
services.AddHandler<WorkspaceSpellCheckEndpoint>();
services.AddSingleton<ISpellCheckService, SpellCheckService>();
services.AddSingleton<ICSharpSpellCheckRangeProvider, LspCSharpSpellCheckRangeProvider>();
services.AddHandlerWithCapabilities<DocumentSpellCheckEndpoint>();
services.AddHandler<WorkspaceSpellCheckEndpoint>();
}

services.AddHandlerWithCapabilities<DocumentDidChangeEndpoint>();
services.AddHandler<DocumentDidCloseEndpoint>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,19 @@
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Razor.SpellCheck;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.AspNetCore.Razor.LanguageServer.SpellCheck;

[RazorLanguageServerEndpoint(VSInternalMethods.TextDocumentSpellCheckableRangesName)]
internal sealed class DocumentSpellCheckEndpoint : IRazorRequestHandler<VSInternalDocumentSpellCheckableParams, VSInternalSpellCheckableRangeReport[]?>, ICapabilitiesProvider
internal sealed class DocumentSpellCheckEndpoint(
ISpellCheckService spellCheckService) : IRazorRequestHandler<VSInternalDocumentSpellCheckableParams, VSInternalSpellCheckableRangeReport[]?>, ICapabilitiesProvider
{
private readonly IDocumentMappingService _documentMappingService;
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions;
private readonly IClientConnection _clientConnection;

public DocumentSpellCheckEndpoint(
IDocumentMappingService documentMappingService,
LanguageServerFeatureOptions languageServerFeatureOptions,
IClientConnection clientConnection)
{
_documentMappingService = documentMappingService ?? throw new ArgumentNullException(nameof(documentMappingService));
_languageServerFeatureOptions = languageServerFeatureOptions ?? throw new ArgumentNullException(nameof(languageServerFeatureOptions));
_clientConnection = clientConnection ?? throw new ArgumentNullException(nameof(clientConnection));
}
private readonly ISpellCheckService _spellCheckService = spellCheckService;

public bool MutatesSolutionState => false;

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

public TextDocumentIdentifier GetTextDocumentIdentifier(VSInternalDocumentSpellCheckableParams request)
{
if (request.TextDocument is null)
{
throw new ArgumentNullException(nameof(request.TextDocument));
}

return request.TextDocument;
}
=> request.TextDocument;

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

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

await AddRazorSpellCheckRangesAsync(ranges, documentContext, cancellationToken).ConfigureAwait(false);

if (_languageServerFeatureOptions.SingleServerSupport)
{
await AddCSharpSpellCheckRangesAsync(ranges, documentContext, cancellationToken).ConfigureAwait(false);
}

return new[]
{
return
[
new VSInternalSpellCheckableRangeReport
{
Ranges = ConvertSpellCheckRangesToIntTriples(ranges),
Ranges = data,
ResultId = Guid.NewGuid().ToString()
}
};
}

private static async Task AddRazorSpellCheckRangesAsync(List<SpellCheckRange> ranges, DocumentContext documentContext, CancellationToken cancellationToken)
{
var tree = await documentContext.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);

// We don't want to report spelling errors in script or style tags, so we avoid descending into them at all, which
// means we don't need complicated logic, and it performs a bit better. We assume any C# in them will still be reported
// by Roslyn.
// 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
// but it doesn't currently support it. When that support is added, we can remove all of this but the RazorCommentBlockSyntax
// handling.
foreach (var node in tree.Root.DescendantNodes(n => n is not MarkupElementSyntax { StartTag.Name.Content: "script" or "style" }))
{
if (node is RazorCommentBlockSyntax commentBlockSyntax)
{
ranges.Add(new((int)VSInternalSpellCheckableRangeKind.Comment, commentBlockSyntax.Comment.SpanStart, commentBlockSyntax.Comment.Span.Length));
}
else if (node is MarkupTextLiteralSyntax textLiteralSyntax)
{
// Attribute names are text literals, but we don't want to spell check them because either C# will,
// whether they're component attributes based on property names, or they come from tag helper attribute
// parameters as strings, or they're Html attributes which are not necessarily expected to be real words.
if (node.Parent is MarkupTagHelperAttributeSyntax or
MarkupAttributeBlockSyntax or
MarkupMinimizedAttributeBlockSyntax or
MarkupTagHelperDirectiveAttributeSyntax or
MarkupMinimizedTagHelperAttributeSyntax or
MarkupMinimizedTagHelperDirectiveAttributeSyntax or
MarkupMiscAttributeContentSyntax)
{
continue;
}

// Text literals appear everywhere in Razor to hold newlines and indentation, so its worth saving the tokens
if (textLiteralSyntax.ContainsOnlyWhitespace())
{
continue;
}

if (textLiteralSyntax.Span.Length == 0)
{
continue;
}

ranges.Add(new((int)VSInternalSpellCheckableRangeKind.String, textLiteralSyntax.SpanStart, textLiteralSyntax.Span.Length));
}
}
}

private async Task AddCSharpSpellCheckRangesAsync(List<SpellCheckRange> ranges, DocumentContext documentContext, CancellationToken cancellationToken)
{
var delegatedParams = new DelegatedSpellCheckParams(documentContext.GetTextDocumentIdentifierAndVersion());
var delegatedResponse = await _clientConnection.SendRequestAsync<DelegatedSpellCheckParams, VSInternalSpellCheckableRangeReport[]?>(
CustomMessageNames.RazorSpellCheckEndpoint,
delegatedParams,
cancellationToken).ConfigureAwait(false);

if (delegatedResponse is null)
{
return;
}

var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
var csharpDocument = codeDocument.GetCSharpDocument();

foreach (var report in delegatedResponse)
{
if (report.Ranges is not { } csharpRanges)
{
continue;
}

// Since we get C# tokens that have relative starts, we need to convert them back to absolute indexes
// so we can sort them with the Razor tokens later
var absoluteCSharpStartIndex = 0;
for (var i = 0; i < csharpRanges.Length; i += 3)
{
var kind = csharpRanges[i];
var start = csharpRanges[i + 1];
var length = csharpRanges[i + 2];

absoluteCSharpStartIndex += start;

// 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
// squiggles that go from C# into Razor/Html.
if (_documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteCSharpStartIndex, out var _1, out var hostDocumentIndex) &&
_documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteCSharpStartIndex + length, out var _2, out var _3))
{
ranges.Add(new(kind, hostDocumentIndex, length));
}

absoluteCSharpStartIndex += length;
}
}
}

private static int[] ConvertSpellCheckRangesToIntTriples(List<SpellCheckRange> ranges)
{
// Important to sort first, or the client will just ignore anything we say
ranges.Sort(CompareSpellCheckRanges);

using var _ = ListPool<int>.GetPooledObject(out var data);
data.SetCapacityIfLarger(ranges.Count * 3);

var lastAbsoluteEndIndex = 0;
foreach (var range in ranges)
{
if (range.Length == 0)
{
continue;
}

data.Add(range.Kind);
data.Add(range.AbsoluteStartIndex - lastAbsoluteEndIndex);
data.Add(range.Length);

lastAbsoluteEndIndex = range.AbsoluteStartIndex + range.Length;
}

return data.ToArray();
}

private record struct SpellCheckRange(int Kind, int AbsoluteStartIndex, int Length);

private static int CompareSpellCheckRanges(SpellCheckRange x, SpellCheckRange y)
{
return x.AbsoluteStartIndex.CompareTo(y.AbsoluteStartIndex);
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.SpellCheck;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.AspNetCore.Razor.LanguageServer.SpellCheck;

internal sealed class LspCSharpSpellCheckRangeProvider(
LanguageServerFeatureOptions languageServerFeatureOptions,
IClientConnection clientConnection) : ICSharpSpellCheckRangeProvider
{
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
private readonly IClientConnection _clientConnection = clientConnection;

public async Task<ImmutableArray<SpellCheckRange>> GetCSharpSpellCheckRangesAsync(DocumentContext documentContext, CancellationToken cancellationToken)
{
if (!_languageServerFeatureOptions.SingleServerSupport)
{
return [];
}

var delegatedParams = new DelegatedSpellCheckParams(documentContext.GetTextDocumentIdentifierAndVersion());
var delegatedResponse = await _clientConnection.SendRequestAsync<DelegatedSpellCheckParams, VSInternalSpellCheckableRangeReport[]?>(
CustomMessageNames.RazorSpellCheckEndpoint,
delegatedParams,
cancellationToken).ConfigureAwait(false);

if (delegatedResponse is not [_, ..] response)
{
return [];
}

// Most common case is we'll get one report back from Roslyn, so we'll use that as the initial capacity.
var initialCapacity = response[0].Ranges?.Length ?? 4;

using var ranges = new PooledArrayBuilder<SpellCheckRange>(initialCapacity);
foreach (var report in delegatedResponse)
{
if (report.Ranges is not { } csharpRanges)
{
continue;
}

// Since we get C# tokens that have relative starts, we need to convert them back to absolute indexes
// so we can sort them with the Razor tokens later
var absoluteCSharpStartIndex = 0;
for (var i = 0; i < csharpRanges.Length; i += 3)
{
var kind = csharpRanges[i];
var start = csharpRanges[i + 1];
var length = csharpRanges[i + 2];

absoluteCSharpStartIndex += start;

ranges.Add(new(kind, absoluteCSharpStartIndex, length));

absoluteCSharpStartIndex += length;
}
}

return ranges.DrainToImmutable();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -308,4 +308,16 @@ static bool IsCSharpCodeBlockSyntax(SyntaxNode node)
return node is CSharpCodeBlockSyntax;
}
}

public static bool IsAnyAttributeSyntax(this SyntaxNode node)
{
return node is
MarkupAttributeBlockSyntax or
MarkupMinimizedAttributeBlockSyntax or
MarkupTagHelperAttributeSyntax or
MarkupMinimizedTagHelperAttributeSyntax or
MarkupTagHelperDirectiveAttributeSyntax or
MarkupMinimizedTagHelperDirectiveAttributeSyntax or
MarkupMiscAttributeContentSyntax;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;

namespace Microsoft.CodeAnalysis.Razor.Remote;

internal interface IRemoteSpellCheckService
{
ValueTask<int[]> GetSpellCheckRangeTriplesAsync(
RazorPinnedSolutionInfoWrapper solutionInfo,
DocumentId razorDocumentId,
CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal static class RazorServices
(typeof(IRemoteDocumentHighlightService), null),
(typeof(IRemoteAutoInsertService), null),
(typeof(IRemoteFormattingService), null),
(typeof(IRemoteSpellCheckService), null),
];

// Internal for testing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;

namespace Microsoft.CodeAnalysis.Razor.SpellCheck;

internal interface ICSharpSpellCheckRangeProvider
{
Task<ImmutableArray<SpellCheckRange>> GetCSharpSpellCheckRangesAsync(DocumentContext documentContext, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;

namespace Microsoft.CodeAnalysis.Razor.SpellCheck;

internal interface ISpellCheckService
{
Task<int[]> GetSpellCheckRangeTriplesAsync(DocumentContext documentContext, CancellationToken cancellationToken);
}
Loading

0 comments on commit 07e1382

Please sign in to comment.