File: SpellCheck\AbstractSpellCheckCodeFixProvider.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// 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.
 
#nullable disable
 
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.SpellCheck;
 
internal abstract class AbstractSpellCheckCodeFixProvider<TSimpleName> : CodeFixProvider
    where TSimpleName : SyntaxNode
{
    private const int MinTokenLength = 3;
 
    public override FixAllProvider GetFixAllProvider()
    {
        // Fix All is not supported by this code fix 
        // https://github.com/dotnet/roslyn/issues/34462
        return null;
    }
 
    protected abstract bool IsGeneric(SyntaxToken nameToken);
    protected abstract bool IsGeneric(TSimpleName nameNode);
    protected abstract bool IsGeneric(CompletionItem completionItem);
    protected abstract SyntaxToken CreateIdentifier(SyntaxToken nameToken, string newName);
 
    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var document = context.Document;
        if (!document.CanApplyChange())
            return;
 
        var span = context.Span;
        var cancellationToken = context.CancellationToken;
 
        var syntaxRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
        var node = syntaxRoot.FindNode(span);
        if (node != null && node.Span == span)
        {
            await CheckNodeAsync(context, document, node, cancellationToken).ConfigureAwait(false);
            return;
        }
 
        // didn't get a node that matches the span.  see if there's a token that matches.
        var token = syntaxRoot.FindToken(span.Start);
        if (token.RawKind != 0 && token.Span == span)
        {
            await CheckTokenAsync(context, document, token, cancellationToken).ConfigureAwait(false);
            return;
        }
    }
 
    private async Task CheckNodeAsync(CodeFixContext context, Document document, SyntaxNode node, CancellationToken cancellationToken)
    {
        SemanticModel semanticModel = null;
        foreach (var name in node.DescendantNodesAndSelf(DescendIntoChildren).OfType<TSimpleName>())
        {
            if (!ShouldSpellCheck(name))
            {
                continue;
            }
 
            // Only bother with identifiers that are at least 3 characters long.
            // We don't want to be too noisy as you're just starting to type something.
            var token = name.GetFirstToken();
            var nameText = token.ValueText;
            if (nameText?.Length >= MinTokenLength)
            {
                semanticModel ??= await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
                var symbolInfo = semanticModel.GetSymbolInfo(name, cancellationToken);
                if (symbolInfo.Symbol == null)
                {
                    await CreateSpellCheckCodeIssueAsync(context, token, IsGeneric(name), cancellationToken).ConfigureAwait(false);
                }
            }
        }
    }
 
    private async Task CheckTokenAsync(CodeFixContext context, Document document, SyntaxToken token, CancellationToken cancellationToken)
    {
        var syntaxFacts = document.GetLanguageService<ISyntaxFactsService>();
        if (!syntaxFacts.IsWord(token))
        {
            return;
        }
 
        var nameText = token.ValueText;
        if (nameText?.Length >= MinTokenLength)
        {
            await CreateSpellCheckCodeIssueAsync(context, token, IsGeneric(token), cancellationToken).ConfigureAwait(false);
        }
    }
 
    protected abstract bool ShouldSpellCheck(TSimpleName name);
    protected abstract bool DescendIntoChildren(SyntaxNode arg);
 
    private async Task CreateSpellCheckCodeIssueAsync(
        CodeFixContext context, SyntaxToken nameToken, bool isGeneric, CancellationToken cancellationToken)
    {
        var document = context.Document;
        var service = CompletionService.GetService(document);
 
        var memberDisplayOptions = await document.GetMemberDisplayOptionsAsync(cancellationToken).ConfigureAwait(false);
 
        // Disable snippets and unimported types from ever appearing in the completion items. 
        // -    It's very unlikely the user would ever misspell a snippet, then use spell-checking to fix it, 
        //      then try to invoke the snippet.
        // -    We believe spell-check should only compare what you have typed to what symbol would be offered here.
        var options = CompletionOptions.Default with
        {
            MemberDisplayOptions = memberDisplayOptions,
            SnippetsBehavior = SnippetsRule.NeverInclude,
            ShowItemsFromUnimportedNamespaces = false,
            TargetTypedCompletionFilter = false,
            ExpandedCompletionBehavior = ExpandedCompletionMode.NonExpandedItemsOnly
        };
 
        var passThroughOptions = document.Project.Solution.Options;
 
        var completionList = await service.GetCompletionsAsync(
            document, nameToken.SpanStart, options, passThroughOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
        if (completionList.ItemsList.IsEmpty())
        {
            return;
        }
 
        var nameText = nameToken.ValueText;
        using var similarityChecker = new WordSimilarityChecker(nameText, substringsAreSimilar: true);
 
        await CheckItemsAsync(
            context, nameToken, isGeneric,
            completionList, similarityChecker).ConfigureAwait(false);
    }
 
    private async Task CheckItemsAsync(
        CodeFixContext context, SyntaxToken nameToken, bool isGeneric,
        CompletionList completionList, WordSimilarityChecker similarityChecker)
    {
        var document = context.Document;
        var cancellationToken = context.CancellationToken;
 
        var onlyConsiderGenerics = isGeneric;
        var results = new MultiDictionary<double, string>();
 
        foreach (var item in completionList.ItemsList)
        {
            if (onlyConsiderGenerics && !IsGeneric(item))
            {
                continue;
            }
 
            var candidateText = item.FilterText;
            if (!similarityChecker.AreSimilar(candidateText, out var matchCost))
            {
                continue;
            }
 
            var insertionText = await GetInsertionTextAsync(document, item, cancellationToken: cancellationToken).ConfigureAwait(false);
            results.Add(matchCost, insertionText);
        }
 
        var nameText = nameToken.ValueText;
        var codeActions = results.OrderBy(kvp => kvp.Key)
                                 .SelectMany(kvp => kvp.Value.Order())
                                 .Where(t => t != nameText)
                                 .Take(3)
                                 .Select(n => CreateCodeAction(nameToken, nameText, n, document))
                                 .ToImmutableArrayOrEmpty<CodeAction>();
 
        if (codeActions.Length > 1)
        {
            // Wrap the spell checking actions into a single top level suggestion
            // so as to not clutter the list.
            context.RegisterCodeFix(
                CodeAction.Create(
                    string.Format(FeaturesResources.Fix_typo_0, nameText),
                    codeActions,
                    isInlinable: true,
                    CodeActionPriority.Low),
                context.Diagnostics);
        }
        else
        {
            context.RegisterFixes(codeActions, context.Diagnostics);
        }
    }
 
    private static readonly char[] s_punctuation = ['(', '[', '<'];
 
    private static async Task<string> GetInsertionTextAsync(Document document, CompletionItem item, CancellationToken cancellationToken)
    {
        var service = CompletionService.GetService(document);
        var change = await service.GetChangeAsync(document, item, commitCharacter: null, cancellationToken).ConfigureAwait(false);
        var text = change.TextChange.NewText;
        var nonCharIndex = text.IndexOfAny(s_punctuation);
        return nonCharIndex > 0
            ? text[0..nonCharIndex]
            : text;
    }
 
    private CodeAction CreateCodeAction(SyntaxToken nameToken, string oldName, string newName, Document document)
    {
        return CodeAction.Create(
            string.Format(FeaturesResources.Change_0_to_1, oldName, newName),
            c => UpdateAsync(document, nameToken, newName, c),
            equivalenceKey: newName,
            CodeActionPriority.Low);
    }
 
    private async Task<Document> UpdateAsync(Document document, SyntaxToken nameToken, string newName, CancellationToken cancellationToken)
    {
        var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
        var newRoot = root.ReplaceToken(nameToken, CreateIdentifier(nameToken, newName));
 
        return document.WithSyntaxRoot(newRoot);
    }
}