File: Handler\SpellCheck\AbstractSpellCheckingHandler.cs
Web Access
Project: src\src\LanguageServer\Protocol\Microsoft.CodeAnalysis.LanguageServer.Protocol.csproj (Microsoft.CodeAnalysis.LanguageServer.Protocol)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics;
using Microsoft.CodeAnalysis.Serialization;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.SpellCheck;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Roslyn.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler.SpellCheck;
 
/// <summary>
/// Root type for both document and workspace spell checking requests.
/// </summary>
internal abstract class AbstractSpellCheckHandler<TParams, TReport>
    : ILspServiceRequestHandler<TParams, TReport[]?>, ITextDocumentIdentifierHandler<TParams, TextDocumentIdentifier?>
    where TParams : IPartialResultParams<TReport[]>
    where TReport : VSInternalSpellCheckableRangeReport
{
    /// <summary>
    /// Cache where we store the data produced by prior requests so that they can be returned if nothing of
    /// significance changed. The version key is produced by combining the checksums for project options <see
    /// cref="ProjectState.GetParseOptionsChecksum"/> and <see cref="DocumentStateChecksums.Text"/>
    /// </summary>
    private readonly SpellCheckPullCache _versionedCache;
 
    public bool MutatesSolutionState => false;
    public bool RequiresLSPSolution => true;
 
    protected AbstractSpellCheckHandler()
    {
        _versionedCache = new(this.GetType().Name);
    }
 
    public abstract TextDocumentIdentifier? GetTextDocumentIdentifier(TParams requestParams);
 
    /// <summary>
    /// Retrieve the previous results we reported.  Used so we can avoid resending data for unchanged files. Also
    /// used so we can report which documents were removed and can have all their spell checking results cleared.
    /// </summary>
    protected abstract ImmutableArray<PreviousPullResult>? GetPreviousResults(TParams requestParams);
 
    /// <summary>
    /// Returns all the documents that should be processed in the desired order to process them in.
    /// </summary>
    protected abstract ImmutableArray<Document> GetOrderedDocuments(RequestContext context, CancellationToken cancellationToken);
 
    /// <summary>
    /// Creates the <see cref="VSInternalSpellCheckableRangeReport"/> instance we'll report back to clients to let them know our
    /// progress.  Subclasses can fill in data specific to their needs as appropriate.
    /// </summary>
    protected abstract TReport CreateReport(TextDocumentIdentifier identifier, int[]? ranges, string? resultId);
 
    public async Task<TReport[]?> HandleRequestAsync(
        TParams requestParams, RequestContext context, CancellationToken cancellationToken)
    {
        context.TraceInformation($"{this.GetType()} started getting spell checking spans");
 
        // The progress object we will stream reports to.
        using var progress = BufferedProgress.Create(requestParams.PartialResultToken);
 
        // Get the set of results the request said were previously reported.  We can use this to determine both
        // what to skip, and what files we have to tell the client have been removed.
        var previousResults = GetPreviousResults(requestParams) ?? [];
        context.TraceInformation($"previousResults.Length={previousResults.Length}");
 
        // First, let the client know if any workspace documents have gone away.  That way it can remove those for
        // the user from squiggles or error-list.
        await HandleRemovedDocumentsAsync(context, previousResults, progress, cancellationToken).ConfigureAwait(false);
 
        // Create a mapping from documents to the previous results the client says it has for them.  That way as we
        // process documents we know if we should tell the client it should stay the same, or we can tell it what
        // the updated spans are.
        var documentToPreviousParams = await GetDocumentToPreviousParamsAsync(context, previousResults, cancellationToken).ConfigureAwait(false);
 
        // Next process each file in priority order. Determine if spans are changed or unchanged since the
        // last time we notified the client.  Report back either to the client so they can update accordingly.
        var orderedDocuments = GetOrderedDocuments(context, cancellationToken);
        context.TraceInformation($"Processing {orderedDocuments.Length} documents");
 
        foreach (var document in orderedDocuments)
        {
            context.TraceInformation($"Processing: {document.FilePath}");
 
            var languageService = document.GetLanguageService<ISpellCheckSpanService>();
            if (languageService == null)
            {
                context.TraceInformation($"Ignoring document '{document.FilePath}' because it does not support spell checking");
                continue;
            }
 
            var documentToPreviousDiagnosticParams = documentToPreviousParams.ToDictionary(kvp => new ProjectOrDocumentId(kvp.Key.Id), kvp => kvp.Value);
            var newResult = await _versionedCache.GetOrComputeNewDataAsync(
                documentToPreviousDiagnosticParams,
                new ProjectOrDocumentId(document.Id),
                document.Project,
                new SpellCheckState(languageService, document),
                cancellationToken).ConfigureAwait(false);
            if (newResult != null)
            {
                var (newResultId, spans) = newResult.Value;
                context.TraceInformation($"Spans were changed for document: {document.FilePath}");
                foreach (var report in ReportCurrentSpans(
                    document, spans, newResultId))
                {
                    progress.Report(report);
                }
            }
            else
            {
                context.TraceInformation($"Spans were unchanged for document: {document.FilePath}");
 
                // Nothing changed between the last request and this one.  Report a (null-spans, same-result-id)
                // response to the client as that means they should just preserve the current spans they have for
                // this file.
                var previousParams = documentToPreviousParams[document];
                progress.Report(CreateReport(previousParams.TextDocument, ranges: null, previousParams.PreviousResultId));
            }
        }
 
        // If we had a progress object, then we will have been reporting to that.  Otherwise, take what we've been
        // collecting and return that.
        context.TraceInformation($"{this.GetType()} finished getting spans");
        return progress.GetFlattenedValues();
    }
 
    private static async Task<Dictionary<Document, PreviousPullResult>> GetDocumentToPreviousParamsAsync(
        RequestContext context, ImmutableArray<PreviousPullResult> previousResults, CancellationToken cancellationToken)
    {
        Contract.ThrowIfNull(context.Solution);
 
        var result = new Dictionary<Document, PreviousPullResult>();
        foreach (var requestParams in previousResults)
        {
            if (requestParams.TextDocument != null)
            {
                var document = await context.Solution.GetDocumentAsync(requestParams.TextDocument, cancellationToken).ConfigureAwait(false);
                if (document != null)
                    result[document] = requestParams;
            }
        }
 
        return result;
    }
 
    private IEnumerable<TReport> ReportCurrentSpans(
        Document document,
        ImmutableArray<SpellCheckSpan> spans,
        string resultId)
    {
        var textDocumentIdentifier = ProtocolConversions.DocumentToTextDocumentIdentifier(document);
 
        // protocol requires the results be in sorted order
        spans = spans.Sort(static (s1, s2) => s1.TextSpan.CompareTo(s2.TextSpan));
 
        if (spans.Length == 0)
        {
            yield return CreateReport(textDocumentIdentifier, [], resultId);
            yield break;
        }
 
        // break things up into batches of 1000 items.  That way we can send smaller messages to the client instead
        // of one enormous one.
        const int chunkSize = 1000;
        var lastSpanEnd = 0;
        for (var batchStart = 0; batchStart < spans.Length; batchStart += chunkSize)
        {
            var batchEnd = Math.Min(batchStart + chunkSize, spans.Length);
            var batchSize = batchEnd - batchStart;
 
            // Each span is encoded as a triple of ints.  The 'kind', the 'relative start', and the 'length'.
            // 'relative start' is the absolute-start for the first span, and then the offset from the end of the
            // last span for all others.
            var triples = new int[batchSize * 3];
            var triplesIndex = 0;
            for (var i = batchStart; i < batchEnd; i++)
            {
                var span = spans[i];
 
                var kind = ProtocolConversions.SpellCheckSpanKindToSpellCheckableRangeKind(span.Kind);
 
                triples[triplesIndex++] = (int)kind;
                triples[triplesIndex++] = span.TextSpan.Start - lastSpanEnd;
                triples[triplesIndex++] = span.TextSpan.Length;
 
                lastSpanEnd = span.TextSpan.End;
            }
 
            Contract.ThrowIfTrue(triplesIndex != triples.Length);
            yield return CreateReport(textDocumentIdentifier, triples, resultId);
        }
    }
 
    private async Task HandleRemovedDocumentsAsync(
        RequestContext context, ImmutableArray<PreviousPullResult> previousResults, BufferedProgress<TReport[]> progress, CancellationToken cancellationToken)
    {
        Contract.ThrowIfNull(context.Solution);
 
        foreach (var previousResult in previousResults)
        {
            var textDocument = previousResult.TextDocument;
            if (textDocument != null)
            {
                var document = await context.Solution.GetTextDocumentAsync(textDocument, cancellationToken).ConfigureAwait(false);
                if (document == null)
                {
                    context.TraceInformation($"Clearing spans for removed document: {textDocument.Uri}");
 
                    // Client is asking server about a document that no longer exists (i.e. was removed/deleted from
                    // the workspace). Report a (null-spans, null-result-id) response to the client as that means
                    // they should just consider the file deleted and should remove all spans information they've
                    // cached for it.
                    progress.Report(CreateReport(textDocument, ranges: null, resultId: null));
                }
            }
        }
    }
}