Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cohost Spell Check #10825

Merged
merged 9 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
DustinCampbell marked this conversation as resolved.
Show resolved Hide resolved

// 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(
davidwengier marked this conversation as resolved.
Show resolved Hide resolved
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
Loading