diff --git a/eng/targets/Services.props b/eng/targets/Services.props index 00a9601d3c8..fc2acc27dfa 100644 --- a/eng/targets/Services.props +++ b/eng/targets/Services.props @@ -31,5 +31,6 @@ + diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs index b717027d9e7..8f12aff65d8 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs @@ -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; @@ -161,10 +162,12 @@ public static void AddTextDocumentServices(this IServiceCollection services, Lan { services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); - } - services.AddHandlerWithCapabilities(); - services.AddHandler(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHandlerWithCapabilities(); + services.AddHandler(); + } services.AddHandlerWithCapabilities(); services.AddHandler(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/DocumentSpellCheckEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/DocumentSpellCheckEndpoint.cs index 625c7a6086b..3cd304c989a 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/DocumentSpellCheckEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/DocumentSpellCheckEndpoint.cs @@ -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, ICapabilitiesProvider +internal sealed class DocumentSpellCheckEndpoint( + ISpellCheckService spellCheckService) : IRazorRequestHandler, 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; @@ -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 HandleRequestAsync(VSInternalDocumentSpellCheckableParams request, RazorRequestContext requestContext, CancellationToken cancellationToken) { @@ -60,150 +34,15 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(VSInternalDocumentSpellC return null; } - using var _ = ListPool.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 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 ranges, DocumentContext documentContext, CancellationToken cancellationToken) - { - var delegatedParams = new DelegatedSpellCheckParams(documentContext.GetTextDocumentIdentifierAndVersion()); - var delegatedResponse = await _clientConnection.SendRequestAsync( - 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 ranges) - { - // Important to sort first, or the client will just ignore anything we say - ranges.Sort(CompareSpellCheckRanges); - - using var _ = ListPool.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); + ]; } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/LspCSharpSpellCheckRangeProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/LspCSharpSpellCheckRangeProvider.cs new file mode 100644 index 00000000000..f0181c75da7 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/SpellCheck/LspCSharpSpellCheckRangeProvider.cs @@ -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> GetCSharpSpellCheckRangesAsync(DocumentContext documentContext, CancellationToken cancellationToken) + { + if (!_languageServerFeatureOptions.SingleServerSupport) + { + return []; + } + + var delegatedParams = new DelegatedSpellCheckParams(documentContext.GetTextDocumentIdentifierAndVersion()); + var delegatedResponse = await _clientConnection.SendRequestAsync( + 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(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(); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RazorSyntaxNodeExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RazorSyntaxNodeExtensions.cs index e3232c19d44..a0d33a9155d 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RazorSyntaxNodeExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RazorSyntaxNodeExtensions.cs @@ -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; + } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteSpellCheckService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteSpellCheckService.cs new file mode 100644 index 00000000000..f7233564653 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteSpellCheckService.cs @@ -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 GetSpellCheckRangeTriplesAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId razorDocumentId, + CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs index 7843e9873af..4ede4292572 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs @@ -23,6 +23,7 @@ internal static class RazorServices (typeof(IRemoteDocumentHighlightService), null), (typeof(IRemoteAutoInsertService), null), (typeof(IRemoteFormattingService), null), + (typeof(IRemoteSpellCheckService), null), ]; // Internal for testing diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/ICSharpSpellCheckRangeProvider.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/ICSharpSpellCheckRangeProvider.cs new file mode 100644 index 00000000000..b25dc9f42dd --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/ICSharpSpellCheckRangeProvider.cs @@ -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> GetCSharpSpellCheckRangesAsync(DocumentContext documentContext, CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/ISpellCheckService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/ISpellCheckService.cs new file mode 100644 index 00000000000..dc45deee3b4 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/ISpellCheckService.cs @@ -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 GetSpellCheckRangeTriplesAsync(DocumentContext documentContext, CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/SpellCheckRange.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/SpellCheckRange.cs new file mode 100644 index 00000000000..472d213f336 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/SpellCheckRange.cs @@ -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); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/SpellCheckService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/SpellCheckService.cs new file mode 100644 index 00000000000..0ab6c5ac690 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/SpellCheck/SpellCheckService.cs @@ -0,0 +1,125 @@ +// 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.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Syntax; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.Razor.SpellCheck; + +internal class SpellCheckService( + ICSharpSpellCheckRangeProvider csharpSpellCheckService, + IDocumentMappingService documentMappingService) : ISpellCheckService +{ + private readonly ICSharpSpellCheckRangeProvider _csharpSpellCheckService = csharpSpellCheckService; + private readonly IDocumentMappingService _documentMappingService = documentMappingService; + + public async Task GetSpellCheckRangeTriplesAsync(DocumentContext documentContext, CancellationToken cancellationToken) + { + using var builder = new PooledArrayBuilder(); + + var syntaxTree = await documentContext.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + + AddRazorSpellCheckRanges(ref builder.AsRef(), syntaxTree); + + var csharpRanges = await _csharpSpellCheckService.GetCSharpSpellCheckRangesAsync(documentContext, cancellationToken).ConfigureAwait(false); + + if (csharpRanges.Length > 0) + { + var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + AddCSharpSpellCheckRanges(ref builder.AsRef(), csharpRanges, codeDocument); + } + + // Important to sort first as we're calculating relative indexes + var ranges = builder.ToImmutableOrderedBy(static r => r.AbsoluteStartIndex); + + return ConvertSpellCheckRangesToIntTriples(ranges); + } + + private static void AddRazorSpellCheckRanges(ref PooledArrayBuilder ranges, RazorSyntaxTree syntaxTree) + { + // 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 syntaxTree.Root.DescendantNodes(static 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.IsAnyAttributeSyntax()) + { + 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 void AddCSharpSpellCheckRanges(ref PooledArrayBuilder ranges, ImmutableArray csharpRanges, RazorCodeDocument codeDocument) + { + var csharpDocument = codeDocument.GetCSharpDocument(); + + foreach (var range in csharpRanges) + { + var absoluteCSharpStartIndex = range.AbsoluteStartIndex; + var length = range.Length; + + // 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 _, out var hostDocumentIndex) && + _documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteCSharpStartIndex + length, out _, out _)) + { + ranges.Add(range with { AbsoluteStartIndex = hostDocumentIndex }); + } + } + } + + private static int[] ConvertSpellCheckRangesToIntTriples(ImmutableArray ranges) + { + using var data = new PooledArrayBuilder(ranges.Length * 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(); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/OOPSpellCheckService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/OOPSpellCheckService.cs new file mode 100644 index 00000000000..1eecb538056 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/OOPSpellCheckService.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Composition; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.SpellCheck; + +namespace Microsoft.CodeAnalysis.Remote.Razor.SpellCheck; + +[Export(typeof(ISpellCheckService)), Shared] +[method: ImportingConstructor] +internal sealed class OOPSpellCheckService( + ICSharpSpellCheckRangeProvider csharpSpellCheckService, + IDocumentMappingService documentMappingService) + : SpellCheckService(csharpSpellCheckService, documentMappingService) +{ +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/RemoteCSharpSpellCheckRangeProvider.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/RemoteCSharpSpellCheckRangeProvider.cs new file mode 100644 index 00000000000..b965764762b --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/RemoteCSharpSpellCheckRangeProvider.cs @@ -0,0 +1,30 @@ +// 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.Composition; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.SpellCheck; +using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; + +namespace Microsoft.CodeAnalysis.Remote.Razor.SpellCheck; + +[Export(typeof(ICSharpSpellCheckRangeProvider)), Shared] +[method: ImportingConstructor] +internal sealed class RemoteCSharpSpellCheckRangeProvider() : ICSharpSpellCheckRangeProvider +{ + public async Task> GetCSharpSpellCheckRangesAsync(DocumentContext documentContext, CancellationToken cancellationToken) + { + // We have a razor document, lets find the generated C# document + Debug.Assert(documentContext is RemoteDocumentContext, "This method only works on document snapshots created in the OOP process"); + var snapshot = (RemoteDocumentSnapshot)documentContext.Snapshot; + var generatedDocument = await snapshot.GetGeneratedDocumentAsync().ConfigureAwait(false); + + var csharpRanges = await ExternalAccess.Razor.Cohost.Handlers.SpellCheck.GetSpellCheckSpansAsync(generatedDocument, cancellationToken).ConfigureAwait(false); + + return csharpRanges.SelectAsArray(static r => new SpellCheckRange((int)r.Kind, r.StartIndex, r.Length)); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/RemoteSpellCheckService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/RemoteSpellCheckService.cs new file mode 100644 index 00000000000..161fbf7b5e3 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SpellCheck/RemoteSpellCheckService.cs @@ -0,0 +1,34 @@ +// 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; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Razor.SpellCheck; +using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; + +namespace Microsoft.CodeAnalysis.Remote.Razor; + +internal sealed partial class RemoteSpellCheckService(in ServiceArgs args) : RazorDocumentServiceBase(in args), IRemoteSpellCheckService +{ + internal sealed class Factory : FactoryBase + { + protected override IRemoteSpellCheckService CreateService(in ServiceArgs args) + => new RemoteSpellCheckService(in args); + } + + private readonly ISpellCheckService _spellCheckService = args.ExportProvider.GetExportedValue(); + + public ValueTask GetSpellCheckRangeTriplesAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + razorDocumentId, + context => GetSpellCheckRangeTriplesAsync(context, cancellationToken), + cancellationToken); + + private async ValueTask GetSpellCheckRangeTriplesAsync(RemoteDocumentContext context, CancellationToken cancellationToken) + { + return await _spellCheckService.GetSpellCheckRangeTriplesAsync(context, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentSpellCheckEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentSpellCheckEndpoint.cs new file mode 100644 index 00000000000..e12e969f886 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentSpellCheckEndpoint.cs @@ -0,0 +1,80 @@ +// 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.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(VSInternalMethods.TextDocumentSpellCheckableRangesName)] +[Export(typeof(IDynamicRegistrationProvider))] +[ExportCohostStatelessLspService(typeof(CohostDocumentSpellCheckEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal sealed class CohostDocumentSpellCheckEndpoint( + IRemoteServiceInvoker remoteServiceInvoker) + : AbstractRazorCohostDocumentRequestHandler, IDynamicRegistrationProvider +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => true; + + public Registration? GetRegistration(VSInternalClientCapabilities clientCapabilities, DocumentFilter[] filter, RazorCohostRequestContext requestContext) + { + if (clientCapabilities.SupportsVisualStudioExtensions) + { + return new Registration + { + Method = VSInternalMethods.TextDocumentSpellCheckableRangesName, + RegisterOptions = new TextDocumentRegistrationOptions() + { + DocumentSelector = filter + } + }; + } + + return null; + } + + protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(VSInternalDocumentSpellCheckableParams request) + => request.TextDocument.ToRazorTextDocumentIdentifier(); + + protected override Task HandleRequestAsync(VSInternalDocumentSpellCheckableParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync(context.TextDocument.AssumeNotNull(), cancellationToken); + + private async Task HandleRequestAsync(TextDocument razorDocument, CancellationToken cancellationToken) + { + var data = await _remoteServiceInvoker.TryInvokeAsync( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => service.GetSpellCheckRangeTriplesAsync(solutionInfo, razorDocument.Id, cancellationToken), + cancellationToken).ConfigureAwait(false); + + return + [ + new VSInternalSpellCheckableRangeReport + { + Ranges = data, + ResultId = Guid.NewGuid().ToString() + } + ]; + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostDocumentSpellCheckEndpoint instance) + { + public Task HandleRequestAsync(TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(razorDocument, cancellationToken); + } +} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostWorkspaceSpellCheckEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostWorkspaceSpellCheckEndpoint.cs new file mode 100644 index 00000000000..79876202a35 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostWorkspaceSpellCheckEndpoint.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Threading; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(VSInternalMethods.WorkspaceSpellCheckableRangesName)] +[ExportCohostStatelessLspService(typeof(CohostWorkspaceSpellCheckEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal sealed class CohostWorkspaceSpellCheckEndpoint( + IRemoteServiceInvoker remoteServiceInvoker) + : AbstractRazorCohostRequestHandler +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => false; + + // Razor files generally don't do anything at the workspace level + + protected override Task HandleRequestAsync(VSInternalWorkspaceSpellCheckableParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => SpecializedTasks.EmptyArray(); +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/SpellCheck/DocumentSpellCheckEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/SpellCheck/DocumentSpellCheckEndpointTest.cs index 72f9c894e4f..a81ed10c7f1 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/SpellCheck/DocumentSpellCheckEndpointTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/SpellCheck/DocumentSpellCheckEndpointTest.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; +using Microsoft.CodeAnalysis.Razor.SpellCheck; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -174,7 +175,9 @@ private async Task ValidateSpellCheckRangesAsync(string originalInput, string? f var documentContext = CreateDocumentContext(uri, codeDocument); var requestContext = new RazorRequestContext(documentContext, null!, "lsp/method", uri: null); - var endpoint = new DocumentSpellCheckEndpoint(DocumentMappingService, LanguageServerFeatureOptions, languageServer); + var csharpSpellCheckService = new LspCSharpSpellCheckRangeProvider(LanguageServerFeatureOptions, languageServer); + var spellCheckService = new SpellCheckService(csharpSpellCheckService, DocumentMappingService); + var endpoint = new DocumentSpellCheckEndpoint(spellCheckService); var request = new VSInternalDocumentSpellCheckableParams { diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/TestCode.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/TestCode.cs index d19cbc5281e..6930bda0d33 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/TestCode.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/TestCode.cs @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Razor.Test.Common; internal readonly struct TestCode { + public string OriginalInput { get; } public string Text { get; } public ImmutableArray Positions { get; } @@ -17,6 +18,8 @@ internal readonly struct TestCode public TestCode(string input, bool treatPositionIndicatorsAsCode = false) { + OriginalInput = input; + if (treatPositionIndicatorsAsCode) { TestFileMarkupParser.GetSpans(input, treatPositionIndicatorsAsCode, out var text, out var nameToSpanMap); diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentSpellCheckEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentSpellCheckEndpointTest.cs new file mode 100644 index 00000000000..6711a4bdc2b --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentSpellCheckEndpointTest.cs @@ -0,0 +1,100 @@ +// 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.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.CodeAnalysis.Text; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +public class CohostDocumentSpellCheckEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) +{ + [Fact] + public async Task Handle() + { + var input = """ + @page [|"this is csharp"|] + +
[| + + Eat more chickin. + + |]
+ + + + + + @{ var [|x|] = [|"csharp"|]; + + @*[| Eat more chickin. |]*@ + +
+ + @code + { + void [|M|]() + { + [|// Eat more chickin|] + } + } + """; + + await VerifySemanticTokensAsync(input); + } + + private async Task VerifySemanticTokensAsync(TestCode input) + { + var document = CreateProjectAndRazorDocument(input.Text); + var sourceText = await document.GetTextAsync(DisposalToken); + + var endpoint = new CohostDocumentSpellCheckEndpoint(RemoteServiceInvoker); + + var span = new LinePositionSpan(new(0, 0), new(sourceText.Lines.Count, 0)); + + var result = await endpoint.GetTestAccessor().HandleRequestAsync(document, DisposalToken); + + var ranges = result.First().Ranges.AssumeNotNull(); + + // To make for easier test failure analysis, we convert the ranges back to the test input, so we can show a diff + // rather than "Expected 23, got 53" and leave the developer to deal with what that means. + // As a bonus, this also ensures the ranges array has the right number of elements (ie, multiple of 3) + var absoluteRanges = new List<(int Start, int End)>(); + var absoluteStart = 0; + for (var i = 0; i < ranges.Length; i += 3) + { + var kind = ranges[i]; + var start = ranges[i + 1]; + var length = ranges[i + 2]; + + absoluteStart += start; + absoluteRanges.Add((absoluteStart, absoluteStart + length)); + absoluteStart += length; + } + + // Make sure the response is sorted correctly, or the IDE will complain + Assert.True(absoluteRanges.SequenceEqual(absoluteRanges.OrderBy(r => r.Start)), "Results are not in order!"); + + absoluteRanges.Reverse(); + + var actual = input.Text; + foreach (var (start, end) in absoluteRanges) + { + actual = actual.Insert(end, "|]").Insert(start, "[|"); + } + + AssertEx.EqualOrDiff(input.OriginalInput, actual); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostOnTypeFormattingEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostOnTypeFormattingEndpointTest.cs index b39ca778abe..7a1e3d913f3 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostOnTypeFormattingEndpointTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostOnTypeFormattingEndpointTest.cs @@ -12,7 +12,6 @@ using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; using Microsoft.VisualStudio.LanguageServer.Protocol; using Microsoft.VisualStudio.Razor.Settings; -using Microsoft.VisualStudio.TestPlatform.ObjectModel; using Microsoft.VisualStudio.Threading; using Roslyn.Test.Utilities; using Xunit;