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
1 change: 1 addition & 0 deletions eng/targets/Services.props
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@
<ServiceHubService Include="Microsoft.VisualStudio.Razor.GoToDefinition" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteGoToDefinitionService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.Rename" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteRenameService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.AutoInsert" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteAutoInsertService+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 @@ -157,10 +158,13 @@ public static void AddTextDocumentServices(this IServiceCollection services, Lan
{
services.AddHandlerWithCapabilities<TextDocumentTextPresentationEndpoint>();
services.AddHandlerWithCapabilities<TextDocumentUriPresentationEndpoint>();

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

services.AddHandlerWithCapabilities<DocumentSpellCheckEndpoint>();
services.AddHandler<WorkspaceSpellCheckEndpoint>();

davidwengier marked this conversation as resolved.
Show resolved Hide resolved
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,
davidwengier marked this conversation as resolved.
Show resolved Hide resolved
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,74 @@
// 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 LspCSharpSpellCheckService(
LanguageServerFeatureOptions languageServerFeatureOptions,
IClientConnection clientConnection) : ICSharpSpellCheckService
{
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 null)
{
return [];
}

using var ranges = new PooledArrayBuilder<SpellCheckRange>();
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.ToImmutable();
}
}
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 @@ -22,6 +22,7 @@ internal static class RazorServices
(typeof(IRemoteFoldingRangeService), null),
(typeof(IRemoteDocumentHighlightService), null),
(typeof(IRemoteAutoInsertService), 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 ICSharpSpellCheckService
{
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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

namespace Microsoft.CodeAnalysis.Razor.SpellCheck;

internal readonly record struct SpellCheckRange(int Kind, int AbsoluteStartIndex, int Length);
Loading
Loading