File: DocumentationComments\AbstractDocumentationCommentCommandHandler.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.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Options;
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;
using Microsoft.VisualStudio.Utilities;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.DocumentationComments;
 
internal abstract class AbstractDocumentationCommentCommandHandler :
    IChainedCommandHandler<TypeCharCommandArgs>,
    ICommandHandler<ReturnKeyCommandArgs>,
    ICommandHandler<InsertCommentCommandArgs>,
    IChainedCommandHandler<OpenLineAboveCommandArgs>,
    IChainedCommandHandler<OpenLineBelowCommandArgs>
{
    private readonly IUIThreadOperationExecutor _uiThreadOperationExecutor;
    private readonly ITextUndoHistoryRegistry _undoHistoryRegistry;
    private readonly IEditorOperationsFactoryService _editorOperationsFactoryService;
    private readonly EditorOptionsService _editorOptionsService;
 
    protected AbstractDocumentationCommentCommandHandler(
        IUIThreadOperationExecutor uiThreadOperationExecutor,
        ITextUndoHistoryRegistry undoHistoryRegistry,
        IEditorOperationsFactoryService editorOperationsFactoryService,
        EditorOptionsService editorOptionsService)
    {
        Contract.ThrowIfNull(uiThreadOperationExecutor);
        Contract.ThrowIfNull(undoHistoryRegistry);
        Contract.ThrowIfNull(editorOperationsFactoryService);
 
        _uiThreadOperationExecutor = uiThreadOperationExecutor;
        _undoHistoryRegistry = undoHistoryRegistry;
        _editorOperationsFactoryService = editorOperationsFactoryService;
        _editorOptionsService = editorOptionsService;
    }
 
    protected abstract string ExteriorTriviaText { get; }
 
    private char TriggerCharacter
    {
        get { return ExteriorTriviaText[^1]; }
    }
 
    public string DisplayName => EditorFeaturesResources.Documentation_Comment;
 
    private static DocumentationCommentSnippet? InsertOnCharacterTyped(IDocumentationCommentSnippetService service, SyntaxTree syntaxTree, SourceText text, int position, DocumentationCommentOptions options, CancellationToken cancellationToken)
        => service.GetDocumentationCommentSnippetOnCharacterTyped(syntaxTree, text, position, options, cancellationToken);
 
    private static DocumentationCommentSnippet? InsertOnEnterTyped(IDocumentationCommentSnippetService service, SyntaxTree syntaxTree, SourceText text, int position, DocumentationCommentOptions options, CancellationToken cancellationToken)
        => service.GetDocumentationCommentSnippetOnEnterTyped(syntaxTree, text, position, options, cancellationToken);
 
    private static DocumentationCommentSnippet? InsertOnCommandInvoke(IDocumentationCommentSnippetService service, SyntaxTree syntaxTree, SourceText text, int position, DocumentationCommentOptions options, CancellationToken cancellationToken)
        => service.GetDocumentationCommentSnippetOnCommandInvoke(syntaxTree, text, position, options, cancellationToken);
 
    private static void ApplySnippet(DocumentationCommentSnippet snippet, ITextBuffer subjectBuffer, ITextView textView)
    {
        var replaceSpan = snippet.SpanToReplace.ToSpan();
        subjectBuffer.Replace(replaceSpan, snippet.SnippetText);
        textView.TryMoveCaretToAndEnsureVisible(subjectBuffer.CurrentSnapshot.GetPoint(replaceSpan.Start + snippet.CaretOffset));
    }
 
    private bool CompleteComment(
        ITextBuffer subjectBuffer,
        ITextView textView,
        Func<IDocumentationCommentSnippetService, SyntaxTree, SourceText, int, DocumentationCommentOptions, CancellationToken, DocumentationCommentSnippet?> getSnippetAction,
        CancellationToken cancellationToken)
    {
        var caretPosition = textView.GetCaretPoint(subjectBuffer) ?? -1;
        if (caretPosition < 0)
        {
            return false;
        }
 
        var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
        {
            return false;
        }
 
        var service = document.GetRequiredLanguageService<IDocumentationCommentSnippetService>();
        var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
        var options = subjectBuffer.GetDocumentationCommentOptions(_editorOptionsService, document.Project.Services);
 
        // Apply snippet in reverse order so that the first applied snippet doesn't affect span of next snippets.
        var snapshots = textView.Selection.GetSnapshotSpansOnBuffer(subjectBuffer).OrderByDescending(s => s.Span.Start);
        var returnValue = false;
        foreach (var snapshot in snapshots)
        {
            var snippet = getSnippetAction(service, parsedDocument.SyntaxTree, parsedDocument.Text, snapshot.Span.Start, options, cancellationToken);
            if (snippet != null)
            {
                ApplySnippet(snippet, subjectBuffer, textView);
                returnValue = true;
            }
        }
 
        return returnValue;
    }
 
    public CommandState GetCommandState(TypeCharCommandArgs args, Func<CommandState> nextHandler)
        => nextHandler();
 
    public void ExecuteCommand(TypeCharCommandArgs args, Action nextHandler, CommandExecutionContext context)
    {
        // Ensure the character is actually typed in the editor
        nextHandler();
 
        if (args.TypedChar != TriggerCharacter)
        {
            return;
        }
 
        // Don't execute in cloud environment, as we let LSP handle that
        if (args.SubjectBuffer.IsInLspEditorContext())
        {
            return;
        }
 
        CompleteComment(args.SubjectBuffer, args.TextView, InsertOnCharacterTyped, CancellationToken.None);
    }
 
    public CommandState GetCommandState(ReturnKeyCommandArgs args)
        => CommandState.Unspecified;
 
    public bool ExecuteCommand(ReturnKeyCommandArgs args, CommandExecutionContext context)
    {
        // Don't execute in cloud environment, as we let LSP handle that
        if (args.SubjectBuffer.IsInLspEditorContext())
        {
            return false;
        }
 
        // Check to see if the current line starts with exterior trivia. If so, we'll take over.
        // If not, let the nextHandler run.
 
        var originalPosition = -1;
 
        // The original position should be a position that is consistent with the syntax tree, even
        // after Enter is pressed. Thus, we use the start of the first selection if there is one.
        // Otherwise, getting the tokens to the right or the left might return unexpected results.
 
        if (args.TextView.Selection.SelectedSpans.Count > 0)
        {
            var selectedSpan = args.TextView.Selection
                .GetSnapshotSpansOnBuffer(args.SubjectBuffer)
                .FirstOrNull();
 
            originalPosition = selectedSpan != null
                ? selectedSpan.Value.Start
                : args.TextView.GetCaretPoint(args.SubjectBuffer) ?? -1;
        }
 
        if (originalPosition < 0)
        {
            return false;
        }
 
        if (!CurrentLineStartsWithExteriorTrivia(args.SubjectBuffer, originalPosition, context.OperationContext.UserCancellationToken))
        {
            return false;
        }
 
        // According to JasonMal, the text undo history is associated with the surface buffer
        // in projection buffer scenarios, so the following line's usage of the surface buffer
        // is correct.
        using var transaction = _undoHistoryRegistry.GetHistory(args.TextView.TextBuffer).CreateTransaction(EditorFeaturesResources.Insert_new_line);
        var editorOperations = _editorOperationsFactoryService.GetEditorOperations(args.TextView);
        editorOperations.InsertNewLine();
 
        CompleteComment(args.SubjectBuffer, args.TextView, InsertOnEnterTyped, CancellationToken.None);
 
        // Since we're wrapping the ENTER key undo transaction, we always complete
        // the transaction -- even if we didn't generate anything.
        transaction.Complete();
 
        return true;
    }
 
    public CommandState GetCommandState(InsertCommentCommandArgs args)
    {
        var caretPosition = args.TextView.GetCaretPoint(args.SubjectBuffer) ?? -1;
        if (caretPosition < 0)
        {
            return CommandState.Unavailable;
        }
 
        var document = args.SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
        {
            return CommandState.Unavailable;
        }
 
        var service = document.GetRequiredLanguageService<IDocumentationCommentSnippetService>();
 
        var isValidTargetMember = false;
        _uiThreadOperationExecutor.Execute("IntelliSense", defaultDescription: "", allowCancellation: true, showProgress: false, action: c =>
        {
            var syntaxTree = document.GetRequiredSyntaxTreeSynchronously(c.UserCancellationToken);
            var text = syntaxTree.GetText(c.UserCancellationToken);
            isValidTargetMember = service.IsValidTargetMember(syntaxTree, text, caretPosition, c.UserCancellationToken);
        });
 
        return isValidTargetMember
            ? CommandState.Available
            : CommandState.Unavailable;
    }
 
    public bool ExecuteCommand(InsertCommentCommandArgs args, CommandExecutionContext context)
    {
        using (context.OperationContext.AddScope(allowCancellation: true, EditorFeaturesResources.Inserting_documentation_comment))
        {
            return CompleteComment(args.SubjectBuffer, args.TextView, InsertOnCommandInvoke, context.OperationContext.UserCancellationToken);
        }
    }
 
    public CommandState GetCommandState(OpenLineAboveCommandArgs args, Func<CommandState> nextHandler)
        => nextHandler();
 
    public void ExecuteCommand(OpenLineAboveCommandArgs args, Action nextHandler, CommandExecutionContext context)
    {
        // Check to see if the current line starts with exterior trivia. If so, we'll take over.
        // If not, let the nextHandler run.
 
        var subjectBuffer = args.SubjectBuffer;
        var caretPosition = args.TextView.GetCaretPoint(subjectBuffer) ?? -1;
        if (caretPosition < 0)
        {
            nextHandler();
            return;
        }
 
        if (!CurrentLineStartsWithExteriorTrivia(subjectBuffer, caretPosition, context.OperationContext.UserCancellationToken))
        {
            nextHandler();
            return;
        }
 
        // Allow nextHandler() to run and then insert exterior trivia if necessary.
        nextHandler();
 
        var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
        {
            return;
        }
 
        var service = document.GetRequiredLanguageService<IDocumentationCommentSnippetService>();
 
        InsertExteriorTriviaIfNeeded(service, args.TextView, subjectBuffer, context.OperationContext.UserCancellationToken);
    }
 
    public CommandState GetCommandState(OpenLineBelowCommandArgs args, Func<CommandState> nextHandler)
        => nextHandler();
 
    public void ExecuteCommand(OpenLineBelowCommandArgs args, Action nextHandler, CommandExecutionContext context)
    {
        // Check to see if the current line starts with exterior trivia. If so, we'll take over.
        // If not, let the nextHandler run.
 
        var subjectBuffer = args.SubjectBuffer;
        var caretPosition = args.TextView.GetCaretPoint(subjectBuffer) ?? -1;
        if (caretPosition < 0)
        {
            nextHandler();
            return;
        }
 
        if (!CurrentLineStartsWithExteriorTrivia(subjectBuffer, caretPosition, context.OperationContext.UserCancellationToken))
        {
            nextHandler();
            return;
        }
 
        var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
        {
            return;
        }
 
        var service = document.GetRequiredLanguageService<IDocumentationCommentSnippetService>();
 
        // Allow nextHandler() to run and the insert exterior trivia if necessary.
        nextHandler();
 
        InsertExteriorTriviaIfNeeded(service, args.TextView, subjectBuffer, context.OperationContext.UserCancellationToken);
    }
 
    private void InsertExteriorTriviaIfNeeded(IDocumentationCommentSnippetService service, ITextView textView, ITextBuffer subjectBuffer, CancellationToken cancellationToken)
    {
        var caretPosition = textView.GetCaretPoint(subjectBuffer) ?? -1;
        if (caretPosition < 0)
        {
            return;
        }
 
        var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
        {
            return;
        }
 
        var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
 
        // We only insert exterior trivia if the current line does not start with exterior trivia
        // and the previous line does.
 
        var currentLine = parsedDocument.Text.Lines.GetLineFromPosition(caretPosition);
        if (currentLine.LineNumber <= 0)
        {
            return;
        }
 
        var previousLine = parsedDocument.Text.Lines[currentLine.LineNumber - 1];
 
        if (LineStartsWithExteriorTrivia(currentLine) || !LineStartsWithExteriorTrivia(previousLine))
        {
            return;
        }
 
        var options = subjectBuffer.GetDocumentationCommentOptions(_editorOptionsService, document.Project.Services);
 
        var snippet = service.GetDocumentationCommentSnippetFromPreviousLine(options, currentLine, previousLine);
        if (snippet != null)
        {
            ApplySnippet(snippet, subjectBuffer, textView);
        }
    }
 
    private bool CurrentLineStartsWithExteriorTrivia(ITextBuffer subjectBuffer, int position, CancellationToken cancellationToken)
    {
        var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
        if (document == null)
        {
            return false;
        }
 
        var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
        var currentLine = parsedDocument.Text.Lines.GetLineFromPosition(position);
 
        return LineStartsWithExteriorTrivia(currentLine);
    }
 
    private bool LineStartsWithExteriorTrivia(TextLine line)
    {
        var lineText = line.ToString();
 
        var lineOffset = lineText.GetFirstNonWhitespaceOffset() ?? -1;
        if (lineOffset < 0)
        {
            return false;
        }
 
        return string.CompareOrdinal(lineText, lineOffset, ExteriorTriviaText, 0, ExteriorTriviaText.Length) == 0;
    }
}