File: DocumentationComments\AbstractXmlTagCompletionCommandHandler.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// 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.Threading;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.Text.Operations;
 
namespace Microsoft.CodeAnalysis.DocumentationComments;
 
internal abstract class AbstractXmlTagCompletionCommandHandler<
    TXmlNameSyntax,
    TXmlTextSyntax,
    TXmlElementSyntax,
    TXmlElementStartTagSyntax,
    TXmlElementEndTagSyntax,
    TDocumentationCommentTriviaSyntax>
    (ITextUndoHistoryRegistry undoHistory) : IChainedCommandHandler<TypeCharCommandArgs>
    where TXmlNameSyntax : SyntaxNode
    where TXmlTextSyntax : SyntaxNode
    where TXmlElementSyntax : SyntaxNode
    where TXmlElementStartTagSyntax : SyntaxNode
    where TXmlElementEndTagSyntax : SyntaxNode
    where TDocumentationCommentTriviaSyntax : SyntaxNode
{
    private readonly ITextUndoHistoryRegistry _undoHistory = undoHistory;
 
    public string DisplayName => EditorFeaturesResources.XML_End_Tag_Completion;
 
    protected abstract TXmlElementStartTagSyntax GetStartTag(TXmlElementSyntax xmlElement);
    protected abstract TXmlElementEndTagSyntax GetEndTag(TXmlElementSyntax xmlElement);
    protected abstract TXmlNameSyntax GetName(TXmlElementStartTagSyntax startTag);
    protected abstract TXmlNameSyntax GetName(TXmlElementEndTagSyntax endTag);
    protected abstract SyntaxToken GetLocalName(TXmlNameSyntax name);
 
    public CommandState GetCommandState(TypeCharCommandArgs args, Func<CommandState> nextHandler)
        => nextHandler();
 
    public void ExecuteCommand(TypeCharCommandArgs args, Action nextHandler, CommandExecutionContext context)
    {
        // Ensure completion and any other buffer edits happen first.
        nextHandler();
 
        var cancellationToken = context.OperationContext.UserCancellationToken;
        if (cancellationToken.IsCancellationRequested)
            return;
 
        try
        {
            ExecuteCommandWorker(args, context);
        }
        catch (OperationCanceledException)
        {
            // According to Editor command handler API guidelines, it's best if we return early if cancellation
            // is requested instead of throwing. Otherwise, we could end up in an invalid state due to already
            // calling nextHandler().
        }
    }
 
    private void ExecuteCommandWorker(TypeCharCommandArgs args, CommandExecutionContext context)
    {
        if (args.TypedChar is not '>' and not '/')
            return;
 
        using (context.OperationContext.AddScope(allowCancellation: true, EditorFeaturesResources.Completing_Tag))
        {
            var buffer = args.SubjectBuffer;
 
            var document = buffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
            if (document == null)
                return;
 
            // We actually want the caret position after any operations
            var position = args.TextView.GetCaretPoint(args.SubjectBuffer);
 
            // No caret position? No edit!
            if (!position.HasValue)
                return;
 
            TryCompleteTag(args.TextView, args.SubjectBuffer, document, position.Value, context.OperationContext.UserCancellationToken);
        }
    }
 
    protected void InsertTextAndMoveCaret(ITextView textView, ITextBuffer subjectBuffer, SnapshotPoint position, string insertionText, int? finalCaretPosition)
    {
        using var transaction = _undoHistory.GetHistory(textView.TextBuffer).CreateTransaction("XmlTagCompletion");
 
        subjectBuffer.Insert(position, insertionText);
 
        if (finalCaretPosition.HasValue)
        {
            var point = subjectBuffer.CurrentSnapshot.GetPoint(finalCaretPosition.Value);
            textView.TryMoveCaretToAndEnsureVisible(point);
        }
 
        transaction.Complete();
    }
 
    private SyntaxToken GetLocalName(TXmlElementStartTagSyntax startTag)
        => GetLocalName(GetName(startTag));
 
    private SyntaxToken GetLocalName(TXmlElementEndTagSyntax startTag)
        => GetLocalName(GetName(startTag));
 
    private void TryCompleteTag(ITextView textView, ITextBuffer subjectBuffer, Document document, SnapshotPoint position, CancellationToken cancellationToken)
    {
        var tree = document.GetRequiredSyntaxTreeSynchronously(cancellationToken);
        var token = tree.FindTokenOnLeftOfPosition(position, cancellationToken, includeDocumentationComments: true);
        var syntaxFacts = document.GetRequiredLanguageService<ISyntaxFactsService>();
        var syntaxKinds = syntaxFacts.SyntaxKinds;
 
        var parentTrivia = token.GetAncestor<TDocumentationCommentTriviaSyntax>();
        if (parentTrivia is null)
            return;
 
        if (token.RawKind == syntaxKinds.GreaterThanToken)
        {
            if (token.Parent is not TXmlElementStartTagSyntax parentStartTag ||
                parentStartTag.Parent is not TXmlElementSyntax parentElement)
            {
                return;
            }
 
            // Slightly special case: <blah><blah$$</blah>
            // If we already have a matching end tag and we're parented by 
            // an xml element with the same start tag and a missing/non-matching end tag, 
            // do completion anyway. Generally, if this is the case, we have to walk
            // up the parent elements until we find an unmatched start tag.
 
            if (GetLocalName(parentStartTag).ValueText.Length > 0 && HasMatchingEndTag(parentElement))
            {
                if (HasUnmatchedIdenticalParent(parentElement))
                {
                    InsertTextAndMoveCaret(textView, subjectBuffer, position, "</" + GetLocalName(parentStartTag).ValueText + ">", position);
                    return;
                }
            }
 
            CheckNameAndInsertText(textView, subjectBuffer, position, parentElement, position.Position, "</{0}>");
        }
        else if (token.RawKind == syntaxKinds.LessThanSlashToken)
        {
            // /// <summary>
            // /// </$$
            // /// </summary>
            // We need to check for non-trivia XML text tokens after $$ that match the expected end tag text.
 
            if (token.Parent is TXmlElementEndTagSyntax { Parent: TXmlElementSyntax parentElement })
            {
                var startTag = GetStartTag(parentElement);
                if (startTag != null &&
                    !HasFollowingEndTagTrivia(startTag, token))
                {
                    CheckNameAndInsertText(textView, subjectBuffer, position, parentElement, null, "{0}>");
                }
            }
        }
    }
 
    private bool HasFollowingEndTagTrivia(
        TXmlElementStartTagSyntax startTag,
        SyntaxToken lessThanSlashToken)
    {
        var tagName = GetLocalName(startTag).ValueText;
        var expectedEndTagText = "</" + tagName + ">";
 
        var token = lessThanSlashToken.GetNextToken(includeDocumentationComments: true);
        while (token.Parent is TXmlTextSyntax)
        {
            if (token.ValueText == expectedEndTagText)
                return true;
 
            token = token.GetNextToken(includeDocumentationComments: true);
        }
 
        if (token.Parent is TXmlElementEndTagSyntax endTag &&
            GetLocalName(endTag).ValueText == tagName)
        {
            return true;
        }
 
        return false;
    }
 
    private bool HasUnmatchedIdenticalParent(TXmlElementSyntax parentElement)
    {
        if (parentElement.Parent is TXmlElementSyntax grandParentElement)
        {
            var parentStartTag = GetStartTag(parentElement);
            if (GetLocalName(GetStartTag(grandParentElement)).ValueText == GetLocalName(parentStartTag).ValueText)
            {
                if (HasMatchingEndTag(grandParentElement))
                {
                    return HasUnmatchedIdenticalParent(grandParentElement);
                }
 
                return true;
            }
        }
 
        return false;
    }
 
    private bool HasMatchingEndTag(TXmlElementSyntax parentElement)
    {
        var startTag = GetStartTag(parentElement);
        var endTag = GetEndTag(parentElement);
        return endTag != null &&
            !endTag.IsMissing &&
            GetLocalName(endTag).ValueText == GetLocalName(startTag).ValueText;
    }
 
    private void CheckNameAndInsertText(
        ITextView textView,
        ITextBuffer subjectBuffer,
        SnapshotPoint position,
        TXmlElementSyntax parentElement,
        int? finalCaretPosition,
        string formatString)
    {
        var startTag = GetStartTag(parentElement);
        var endTag = GetEndTag(parentElement);
        if (startTag is null || endTag is null)
            return;
 
        var elementName = GetLocalName(startTag).ValueText;
 
        if (elementName.Length > 0 &&
            GetLocalName(endTag).ValueText != elementName)
        {
            InsertTextAndMoveCaret(textView, subjectBuffer, position, string.Format(formatString, elementName), finalCaretPosition);
        }
    }
}