File: ReplaceDocCommentTextWithTag\AbstractReplaceDocCommentTextWithTagCodeRefactoringProvider.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.
 
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.ReplaceDocCommentTextWithTag;
 
internal abstract class AbstractReplaceDocCommentTextWithTagCodeRefactoringProvider : CodeRefactoringProvider
{
    protected abstract bool IsInXMLAttribute(SyntaxToken token);
    protected abstract bool IsKeyword(string text);
    protected abstract bool IsXmlTextToken(SyntaxToken token);
    protected abstract SyntaxNode ParseExpression(string text);
 
    public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
    {
        var (document, span, cancellationToken) = context;
        var root = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
        var token = root.FindToken(span.Start, findInsideTrivia: true);
 
        if (!IsXmlTextToken(token))
            return;
 
        if (!token.FullSpan.Contains(span))
            return;
 
        if (IsInXMLAttribute(token))
            return;
 
        var sourceText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
 
        var singleWordSpan = ExpandSpan(sourceText, span, fullyQualifiedName: false);
        var singleWordText = sourceText.ToString(singleWordSpan);
        if (singleWordText == "")
            return;
 
        // First see if they're on an appropriate keyword. 
        if (IsKeyword(singleWordText))
        {
            RegisterRefactoring(context, singleWordSpan, $@"<see langword=""{singleWordText}""/>");
            return;
        }
 
        // Not a keyword, see if it semantically means anything in the current context.
        var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
        var symbol = GetEnclosingSymbol(semanticModel, span.Start, cancellationToken);
        if (symbol == null)
            return;
 
        // See if we can expand the term out to a fully qualified name. Do this
        // first in case the user has something like X.memberName.  We don't want 
        // to try to bind "memberName" first as it might bind to something like 
        // a parameter, which is not what the user intends
        var fullyQualifiedSpan = ExpandSpan(sourceText, span, fullyQualifiedName: true);
        if (fullyQualifiedSpan != singleWordSpan)
        {
            var fullyQualifiedText = sourceText.ToString(fullyQualifiedSpan);
            if (TryRegisterSeeCrefTagIfSymbol(
                    context, semanticModel, token, fullyQualifiedSpan, cancellationToken))
            {
                return;
            }
        }
 
        // Check if the single word could be binding to a type parameter or parameter
        // for the current symbol.
        var syntaxFacts = document.GetRequiredLanguageService<ISyntaxFactsService>();
        var parameter = symbol.GetParameters().FirstOrDefault(p => syntaxFacts.StringComparer.Equals(p.Name, singleWordText));
        if (parameter != null)
        {
            RegisterRefactoring(context, singleWordSpan, $@"<paramref name=""{singleWordText}""/>");
            return;
        }
 
        var typeParameter = symbol.GetTypeParameters().FirstOrDefault(t => syntaxFacts.StringComparer.Equals(t.Name, singleWordText));
        if (typeParameter != null)
        {
            RegisterRefactoring(context, singleWordSpan, $@"<typeparamref name=""{singleWordText}""/>");
            return;
        }
 
        // Doc comments on a named type can see the members inside of it.  So check
        // inside the named type for a member that matches.
        if (symbol is INamedTypeSymbol namedType)
        {
            var childMember = namedType.GetMembers().FirstOrDefault(m => syntaxFacts.StringComparer.Equals(m.Name, singleWordText));
            if (childMember != null)
            {
                RegisterRefactoring(context, singleWordSpan, $@"<see cref=""{singleWordText}""/>");
                return;
            }
        }
 
        // Finally, try to speculatively bind the name and see if it binds to anything
        // in the surrounding context.
        TryRegisterSeeCrefTagIfSymbol(
            context, semanticModel, token, singleWordSpan, cancellationToken);
    }
 
    private bool TryRegisterSeeCrefTagIfSymbol(
        CodeRefactoringContext context, SemanticModel semanticModel, SyntaxToken token, TextSpan replacementSpan, CancellationToken cancellationToken)
    {
        var sourceText = semanticModel.SyntaxTree.GetText(cancellationToken);
        var text = sourceText.ToString(replacementSpan);
 
        var parsed = ParseExpression(text);
        var foundSymbol = semanticModel.GetSpeculativeSymbolInfo(token.SpanStart, parsed, SpeculativeBindingOption.BindAsExpression).GetAnySymbol();
        if (foundSymbol == null)
        {
            return false;
        }
 
        RegisterRefactoring(context, replacementSpan, $@"<see cref=""{text}""/>");
        return true;
    }
 
    private static ISymbol? GetEnclosingSymbol(SemanticModel semanticModel, int position, CancellationToken cancellationToken)
    {
        var root = semanticModel.SyntaxTree.GetRoot(cancellationToken);
        var token = root.FindToken(position);
 
        for (var node = token.Parent; node != null; node = node.Parent)
        {
            if (semanticModel.GetDeclaredSymbol(node, cancellationToken) is ISymbol declaration)
                return declaration;
        }
 
        return null;
    }
 
    private static void RegisterRefactoring(
        CodeRefactoringContext context, TextSpan expandedSpan, string replacement)
    {
        context.RegisterRefactoring(
            CodeAction.Create(
                string.Format(FeaturesResources.Use_0, replacement),
                c => ReplaceTextAsync(context.Document, expandedSpan, replacement, c),
                nameof(FeaturesResources.Use_0) + "_" + replacement),
            expandedSpan);
    }
 
    private static async Task<Document> ReplaceTextAsync(
        Document document, TextSpan span, string replacement, CancellationToken cancellationToken)
    {
        var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
        var newText = text.Replace(span, replacement);
 
        return document.WithText(newText);
    }
 
    private static TextSpan ExpandSpan(SourceText sourceText, TextSpan span, bool fullyQualifiedName)
    {
        if (span.Length != 0)
        {
            return span;
        }
 
        var startInclusive = span.Start;
        var endExclusive = span.Start;
        while (startInclusive > 0 &&
               ShouldExpandSpanBackwardOneCharacter(sourceText, startInclusive, fullyQualifiedName))
        {
            startInclusive--;
        }
 
        while (endExclusive < sourceText.Length &&
               ShouldExpandSpanForwardOneCharacter(sourceText, endExclusive, fullyQualifiedName))
        {
            endExclusive++;
        }
 
        return TextSpan.FromBounds(startInclusive, endExclusive);
    }
 
    private static bool ShouldExpandSpanForwardOneCharacter(
        SourceText sourceText, int endExclusive, bool fullyQualifiedName)
    {
        var currentChar = sourceText[endExclusive];
 
        if (char.IsLetterOrDigit(currentChar))
        {
            return true;
        }
 
        // Only consume a dot in front of the current word if it is part of a dotted
        // word chain, and isn't just the end of a sentence.
        if (fullyQualifiedName && currentChar == '.' &&
            endExclusive + 1 < sourceText.Length && char.IsLetterOrDigit(sourceText[endExclusive + 1]))
        {
            return true;
        }
 
        return false;
    }
 
    private static bool ShouldExpandSpanBackwardOneCharacter(
        SourceText sourceText, int startInclusive, bool fullyQualifiedName)
    {
        Debug.Assert(startInclusive > 0);
 
        var previousCharacter = sourceText[startInclusive - 1];
        if (char.IsLetterOrDigit(previousCharacter))
        {
            return true;
        }
 
        if (fullyQualifiedName && previousCharacter == '.')
        {
            return true;
        }
 
        return false;
    }
}