File: DocumentationComments\AbstractDocumentationCommentCommandHandler.cs
Web Access
Project: ..\..\..\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;
        }
    }
}