File: DocumentationComments\CopilotGenerateDocumentationCommentProvider.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.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Copilot;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Threading;
using Microsoft.VisualStudio.Language.Proposals;
using Microsoft.VisualStudio.Language.Suggestions;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.DocumentationComments
{
    internal class CopilotGenerateDocumentationCommentProvider : SuggestionProviderBase
    {
        private SuggestionManagerBase? _suggestionManager;
        private readonly ICopilotCodeAnalysisService _copilotService;
 
        public readonly IThreadingContext ThreadingContext;
 
        public bool Enabled => _enabled && (_suggestionManager != null);
        private bool _enabled = true;
 
        public CopilotGenerateDocumentationCommentProvider(IThreadingContext threadingContext, ICopilotCodeAnalysisService copilotService)
        {
            _copilotService = copilotService;
            ThreadingContext = threadingContext;
        }
 
        public async Task InitializeAsync(ITextView textView, SuggestionServiceBase suggestionServiceBase, CancellationToken cancellationToken)
        {
            _suggestionManager ??= await suggestionServiceBase.TryRegisterProviderAsync(this, textView, "AmbientAIDocumentationComments", cancellationToken).ConfigureAwait(false);
        }
 
        public async Task GenerateDocumentationProposalAsync(DocumentationCommentSnippet snippet,
            ITextSnapshot oldSnapshot, VirtualSnapshotPoint oldCaret, CancellationToken cancellationToken)
        {
            await Task.Yield().ConfigureAwait(false);
 
            if (!Enabled)
            {
                return;
            }
 
            // MemberNode is not null at this point, checked when determining if the file is exluded.
            var snippetProposal = GetSnippetProposal(snippet.SnippetText, snippet.MemberNode!, snippet.Position, snippet.CaretOffset);
 
            if (snippetProposal is null)
            {
                return;
            }
 
            // Do not do IntelliCode line completions if we're about to generate a documentation comment
            // so that won't have interfering grey text.
            var intellicodeLineCompletionsDisposable = await _suggestionManager!.DisableProviderAsync(SuggestionServiceNames.IntelliCodeLineCompletions, cancellationToken).ConfigureAwait(false);
 
            var proposalEdits = await GetProposedEditsAsync(snippetProposal, _copilotService, oldSnapshot, snippet.IndentText, cancellationToken).ConfigureAwait(false);
 
            var proposal = Proposal.TryCreateProposal(null, proposalEdits, oldCaret, flags: ProposalFlags.SingleTabToAccept);
 
            if (proposal is null)
            {
                await intellicodeLineCompletionsDisposable.DisposeAsync().ConfigureAwait(false);
                return;
            }
 
            var suggestion = new DocumentationCommentSuggestion(this, proposal, _suggestionManager, intellicodeLineCompletionsDisposable);
            await suggestion.TryDisplaySuggestionAsync(cancellationToken).ConfigureAwait(false);
        }
 
        /// <summary>
        /// Traverses the documentation comment shell and retrieves the pieces that are needed to generate the documentation comment.
        /// </summary>
        private static DocumentationCommentProposal? GetSnippetProposal(string comments, SyntaxNode memberNode, int? position, int caret)
        {
            if (position is null)
            {
                return null;
            }
 
            var startIndex = position.Value;
            var proposedEdits = ArrayBuilder<DocumentationCommentProposedEdit>.GetInstance();
            var index = 0;
 
            var summaryStartTag = comments.IndexOf("<summary>", index, StringComparison.Ordinal);
            var summaryEndTag = comments.IndexOf("</summary>", index, StringComparison.Ordinal);
            if (summaryEndTag != -1 && summaryStartTag != -1)
            {
                proposedEdits.Add(new DocumentationCommentProposedEdit(new TextSpan(caret + startIndex, 0), null, DocumentationCommentTagType.Summary));
            }
 
            while (true)
            {
                var typeParamEndTag = comments.IndexOf("</typeparam>", index, StringComparison.Ordinal);
                var typeParamStartTag = comments.IndexOf("<typeparam name=\"", index, StringComparison.Ordinal);
 
                if (typeParamStartTag == -1 || typeParamEndTag == -1)
                {
                    break;
                }
 
                var typeParamNameStart = typeParamStartTag + "<typeparam name=\"".Length;
                var typeParamNameEnd = comments.IndexOf("\">", typeParamNameStart, StringComparison.Ordinal);
                if (typeParamNameEnd != -1)
                {
                    var parameterName = comments.Substring(typeParamNameStart, typeParamNameEnd - typeParamNameStart);
                    proposedEdits.Add(new DocumentationCommentProposedEdit(new TextSpan(typeParamEndTag + startIndex, 0), parameterName, DocumentationCommentTagType.TypeParam));
                }
 
                index = typeParamEndTag + "</typeparam>".Length;
            }
 
            while (true)
            {
                var paramEndTag = comments.IndexOf("</param>", index, StringComparison.Ordinal);
                var paramStartTag = comments.IndexOf("<param name=\"", index, StringComparison.Ordinal);
 
                if (paramStartTag == -1 || paramEndTag == -1)
                {
                    break;
                }
 
                var paramNameStart = paramStartTag + "<param name=\"".Length;
                var paramNameEnd = comments.IndexOf("\">", paramNameStart, StringComparison.Ordinal);
                if (paramNameEnd != -1)
                {
                    var parameterName = comments.Substring(paramNameStart, paramNameEnd - paramNameStart);
                    proposedEdits.Add(new DocumentationCommentProposedEdit(new TextSpan(paramEndTag + startIndex, 0), parameterName, DocumentationCommentTagType.Param));
                }
 
                index = paramEndTag + "</param>".Length;
            }
 
            var returnsEndTag = comments.IndexOf("</returns>", index, StringComparison.Ordinal);
            if (returnsEndTag != -1)
            {
                proposedEdits.Add(new DocumentationCommentProposedEdit(new TextSpan(returnsEndTag + startIndex, 0), null, DocumentationCommentTagType.Returns));
            }
 
            while (true)
            {
                var exceptionEndTag = comments.IndexOf("</exception>", index, StringComparison.Ordinal);
                var exceptionStartTag = comments.IndexOf("<exception cref=\"", index, StringComparison.Ordinal);
 
                if (exceptionEndTag == -1 || exceptionStartTag == -1)
                {
                    break;
                }
 
                var exceptionNameStart = exceptionStartTag + "<exception cref=\"".Length;
                var exceptionNameEnd = comments.IndexOf("\">", exceptionNameStart, StringComparison.Ordinal);
                if (exceptionNameEnd != -1)
                {
                    var exceptionName = comments.Substring(exceptionNameStart, exceptionNameEnd - exceptionNameStart);
                    proposedEdits.Add(new DocumentationCommentProposedEdit(new TextSpan(exceptionEndTag + startIndex, 0), exceptionName, DocumentationCommentTagType.Exception));
                }
 
                index = exceptionEndTag + "</exception>".Length;
            }
 
            return new DocumentationCommentProposal(memberNode.ToFullString(), proposedEdits.ToImmutableArray());
        }
 
        /// <summary>
        /// Calls into the copilot service to get the pieces for the documentation comment.
        /// </summary>
        private static async Task<IReadOnlyList<ProposedEdit>> GetProposedEditsAsync(
            DocumentationCommentProposal proposal, ICopilotCodeAnalysisService copilotService,
            ITextSnapshot oldSnapshot, string? indentText, CancellationToken cancellationToken)
        {
            var list = new List<ProposedEdit>();
            var (documentationCommentDictionary, isQuotaExceeded) = await copilotService.GetDocumentationCommentAsync(proposal, cancellationToken).ConfigureAwait(false);
 
            // Quietly fail if the quota has been exceeded.
            if (isQuotaExceeded)
            {
                return list;
            }
 
            if (documentationCommentDictionary is null)
            {
                return list;
            }
 
            if (documentationCommentDictionary.Count == 0)
            {
                return list;
            }
 
            foreach (var edit in proposal.ProposedEdits)
            {
                string? copilotStatement = null;
                var textSpan = edit.SpanToReplace;
 
                string? symbolKey = null;
 
                if (edit.SymbolName is not null)
                {
                    symbolKey = edit.TagType.ToString() + "- " + edit.SymbolName;
                }
 
                if (edit.TagType == DocumentationCommentTagType.Summary && documentationCommentDictionary.TryGetValue(DocumentationCommentTagType.Summary.ToString(), out var summary) && !string.IsNullOrEmpty(summary))
                {
                    copilotStatement = summary;
                }
                if (edit.TagType == DocumentationCommentTagType.TypeParam && documentationCommentDictionary.TryGetValue(symbolKey!, out var typeParam) && !string.IsNullOrEmpty(typeParam))
                {
                    copilotStatement = typeParam;
                }
                else if (edit.TagType == DocumentationCommentTagType.Param && documentationCommentDictionary.TryGetValue(symbolKey!, out var param) && !string.IsNullOrEmpty(param))
                {
                    copilotStatement = param;
                }
                else if (edit.TagType == DocumentationCommentTagType.Returns && documentationCommentDictionary.TryGetValue(DocumentationCommentTagType.Returns.ToString(), out var returns) && !string.IsNullOrEmpty(returns))
                {
                    copilotStatement = returns;
                }
                else if (edit.TagType == DocumentationCommentTagType.Exception && documentationCommentDictionary.TryGetValue(symbolKey!, out var exception) && !string.IsNullOrEmpty(exception))
                {
                    copilotStatement = exception;
                }
 
                var proposedEdit = new ProposedEdit(new SnapshotSpan(oldSnapshot, textSpan.Start, textSpan.Length),
                    AddNewLinesToCopilotText(copilotStatement!, indentText, characterLimit: 120));
                list.Add(proposedEdit);
            }
 
            return list;
 
            static string AddNewLinesToCopilotText(string copilotText, string? indentText, int characterLimit)
            {
                var builder = new StringBuilder();
                var words = copilotText.Split(' ');
                var currentLineLength = 0;
                characterLimit -= (indentText!.Length + "/// ".Length);
                foreach (var word in words)
                {
                    if (currentLineLength + word.Length >= characterLimit)
                    {
                        builder.AppendLine();
                        builder.Append(indentText);
                        builder.Append("/// ");
                        currentLineLength = 0;
                    }
 
                    if (currentLineLength > 0)
                    {
                        builder.Append(' ');
                        currentLineLength++;
                    }
 
                    builder.Append(word);
                    currentLineLength += word.Length;
                }
 
                return builder.ToString();
            }
        }
 
        public override Task EnabledAsync(CancellationToken cancel)
        {
            _enabled = true;
            return Task.CompletedTask;
        }
 
        public override Task DisabledAsync(CancellationToken cancel)
        {
            _enabled = false;
            return Task.CompletedTask;
        }
    }
}