File: DocumentationComments\CopilotDocumentationCommentGenerator.cs
Web Access
Project: src\roslyn\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;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Copilot;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeAnalysis.DocumentationComments;

/// <summary>
/// Editor-agnostic core for Copilot-generated documentation comments: builds the proposal from the
/// documentation comment shell, calls the Copilot service, and produces the resulting edits. It carries no
/// dependency on the editor's suggestion/proposal types so it can be reused by both the grey-text suggestion
/// path and the InlinePrompt chip-accept path.
/// </summary>
internal static class CopilotDocumentationCommentGenerator
{
    /// <summary>
    /// Traverses the documentation comment shell and retrieves the pieces that are needed to generate the
    /// documentation comment.
    /// </summary>
    public 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), symbolName: null, DocumentationCommentTagType.Summary));
        }

        // We may receive remarks from the model. In that case, we want to insert the remark tags and remark directly after the summary.
        proposedEdits.Add(new DocumentationCommentProposedEdit(new TextSpan(summaryEndTag + "</summary>".Length + startIndex, 0), symbolName: null, DocumentationCommentTagType.Remarks));

        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), symbolName: 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.ToImmutableAndFree());
    }

    /// <summary>
    /// Calls into the Copilot service to get the pieces for the documentation comment and maps them onto the
    /// spans described by <paramref name="proposal"/>. Returns an empty array when the quota has been exceeded
    /// or the service returns no content.
    /// </summary>
    public static async Task<ImmutableArray<DocumentationCommentEdit>> GenerateEditsAsync(
        DocumentationCommentProposal proposal, ICopilotCodeAnalysisService copilotService,
        string? indentText, CancellationToken cancellationToken)
    {
        var (documentationCommentDictionary, isQuotaExceeded) = await copilotService.GetDocumentationCommentAsync(proposal, cancellationToken).ConfigureAwait(false);

        // Quietly fail if the quota has been exceeded.
        if (isQuotaExceeded)
        {
            return [];
        }

        if (documentationCommentDictionary is null || documentationCommentDictionary.Count == 0)
        {
            return [];
        }

        var edits = ArrayBuilder<DocumentationCommentEdit>.GetInstance();

        foreach (var edit in proposal.ProposedEdits)
        {
            string? symbolKey = null;

            if (edit.SymbolName is not null)
            {
                symbolKey = edit.TagType.ToString() + "-" + edit.SymbolName;
            }

            var copilotStatement = GetCopilotStatement(documentationCommentDictionary, edit, symbolKey);

            // Just skip this piece of the documentation comment if, for some reason, it is not found.
            if (copilotStatement is null)
            {
                continue;
            }

            var replacementText = AddNewLinesToCopilotText(copilotStatement, indentText, edit.TagType, characterLimit: 120);
            edits.Add(new DocumentationCommentEdit(edit.SpanToReplace, replacementText));
        }

        return edits.ToImmutableAndFree();

        static string? GetCopilotStatement(Dictionary<string, string> documentationCommentDictionary, DocumentationCommentProposedEdit edit, string? symbolKey)
        {
            if (edit.TagType == DocumentationCommentTagType.Summary && documentationCommentDictionary.TryGetValue(DocumentationCommentTagType.Summary.ToString(), out var summary) && !string.IsNullOrEmpty(summary))
            {
                return summary;
            }
            else if (edit.TagType == DocumentationCommentTagType.Remarks && documentationCommentDictionary.TryGetValue(DocumentationCommentTagType.Remarks.ToString(), out var remarks) && !string.IsNullOrEmpty(remarks))
            {
                return remarks;
            }
            else if (edit.TagType == DocumentationCommentTagType.TypeParam && documentationCommentDictionary.TryGetValue(symbolKey!, out var typeParam) && !string.IsNullOrEmpty(typeParam))
            {
                return typeParam;
            }
            else if (edit.TagType == DocumentationCommentTagType.Param && documentationCommentDictionary.TryGetValue(symbolKey!, out var param) && !string.IsNullOrEmpty(param))
            {
                return param;
            }
            else if (edit.TagType == DocumentationCommentTagType.Returns && documentationCommentDictionary.TryGetValue(DocumentationCommentTagType.Returns.ToString(), out var returns) && !string.IsNullOrEmpty(returns))
            {
                return returns;
            }
            else if (edit.TagType == DocumentationCommentTagType.Exception && documentationCommentDictionary.TryGetValue(symbolKey!, out var exception) && !string.IsNullOrEmpty(exception))
            {
                return exception;
            }

            return null;
        }

        static string AddNewLinesToCopilotText(string copilotText, string? indentText, DocumentationCommentTagType tagType, int characterLimit)
        {
            // Double check that the resultant from Copilot does not produce any strings containing new line characters.
            copilotText = Regex.Replace(copilotText, @"\r?\n", " ");
            var builder = new StringBuilder();
            copilotText = BuildCopilotTextForRemarks(copilotText, indentText, tagType, builder);

            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();

            static string BuildCopilotTextForRemarks(string copilotText, string? indentText, DocumentationCommentTagType tagType, StringBuilder builder)
            {
                if (tagType is DocumentationCommentTagType.Remarks)
                {
                    builder.AppendLine();
                    builder.Append(indentText);
                    builder.Append("/// <remarks>");
                    builder.Append(copilotText);
                    builder.Append("</remarks>");
                    copilotText = builder.ToString();
                    builder.Clear();
                }

                return copilotText;
            }
        }
    }
}