File: SpellCheck\SpellCheckService.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
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;
 
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<int[]> GetSpellCheckRangeTriplesAsync(DocumentContext documentContext, CancellationToken cancellationToken)
    {
        using var builder = new PooledArrayBuilder<SpellCheckRange>();
 
        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<SpellCheckRange> 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 BaseMarkupElementSyntax element || !RazorSyntaxFacts.IsScriptOrStyleBlock(element)))
        {
            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<SpellCheckRange> ranges, ImmutableArray<SpellCheckRange> csharpRanges, RazorCodeDocument codeDocument)
    {
        var csharpDocument = codeDocument.GetRequiredCSharpDocument();
 
        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.TryMapToRazorDocumentPosition(csharpDocument, absoluteCSharpStartIndex, out _, out var hostDocumentIndex) &&
                _documentMappingService.TryMapToRazorDocumentPosition(csharpDocument, absoluteCSharpStartIndex + length, out _, out _))
            {
                ranges.Add(range with { AbsoluteStartIndex = hostDocumentIndex });
            }
        }
    }
 
    private static int[] ConvertSpellCheckRangesToIntTriples(ImmutableArray<SpellCheckRange> ranges)
    {
        using var data = new PooledArrayBuilder<int>(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();
    }
}