File: src\roslyn\src\Analyzers\CSharp\CodeFixes\UsePrimaryConstructor\CSharpUsePrimaryConstructorCodeFixProvider_DocComments.cs
Web Access
Project: src\src\roslyn\src\CodeStyle\CSharp\CodeFixes\Microsoft.CodeAnalysis.CSharp.CodeStyle.Fixes.csproj (Microsoft.CodeAnalysis.CSharp.CodeStyle.Fixes)
// 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.

// Ignore Spelling: loc kvp

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.LanguageService;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Collections;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.CSharp.UsePrimaryConstructor;

using static SyntaxFactory;

internal sealed partial class CSharpUsePrimaryConstructorCodeFixProvider : CodeFixProvider
{
    private const string s_summaryTagName = "summary";
    private const string s_remarksTagName = "remarks";
    private const string s_paramTagName = "param";

    // trivial helpers for working xml doc comments nodes/trivia

    private static SyntaxTrivia GetDocComment(SyntaxNode node)
        => GetDocComment(node.GetLeadingTrivia());

    private static SyntaxTrivia GetDocComment(SyntaxTriviaList trivia)
        => trivia.LastOrDefault(t => t.IsSingleLineDocComment());

    private static DocumentationCommentTriviaSyntax? GetDocCommentStructure(MemberDeclarationSyntax node)
        => GetDocCommentStructure(node.GetLeadingTrivia());

    private static DocumentationCommentTriviaSyntax? GetDocCommentStructure(SyntaxTriviaList trivia)
        => GetDocCommentStructure(GetDocComment(trivia));

    private static DocumentationCommentTriviaSyntax? GetDocCommentStructure(SyntaxTrivia trivia)
        => (DocumentationCommentTriviaSyntax?)trivia.GetStructure();

    private static bool IsXmlElement(XmlNodeSyntax node, string name, [NotNullWhen(true)] out XmlElementSyntax? element)
    {
        element = node is XmlElementSyntax { StartTag.Name.LocalName.ValueText: var elementName } xmlElement && elementName == name
            ? xmlElement
            : null;
        return element != null;
    }

    private static XmlElementSyntax ConvertXmlElementName(XmlElementSyntax xmlElement, string name)
    {
        return xmlElement.ReplaceTokens(
            [xmlElement.StartTag.Name.LocalName, xmlElement.EndTag.Name.LocalName],
            (token, _) => Identifier(name).WithTriviaFrom(token));
    }

    private static SyntaxTriviaList CreateFinalTypeDeclarationLeadingTrivia(
        TypeDeclarationSyntax typeDeclaration,
        ConstructorDeclarationSyntax constructorDeclaration,
        IMethodSymbol constructor,
        ImmutableDictionary<string, string?> properties,
        ImmutableDictionary<ISymbol, (MemberDeclarationSyntax memberNode, SyntaxNode nodeToRemove)> removedMembers)
    {
        // First, take the constructor doc comments and merge those into the type's doc comments.
        // Then, take any removed fields/properties and merge their comments into the type's doc comments.

        var typeDeclarationLeadingTrivia = MergeTypeDeclarationAndConstructorDocComments(typeDeclaration, constructorDeclaration);
        var finalLeadingTrivia = MergeTypeDeclarationAndRemovedMembersDocComments(constructor, properties, removedMembers, typeDeclarationLeadingTrivia);

        return finalLeadingTrivia;

        static IEnumerable<XmlNodeSyntax> ConvertSummaryToParam(IEnumerable<XmlNodeSyntax> content, string parameterName)
        {
            foreach (var node in content)
            {
                yield return IsXmlElement(node, s_summaryTagName, out var xmlElement)
                    ? ConvertXmlElementName(xmlElement, s_paramTagName).AddStartTagAttributes(XmlNameAttribute(CSharpSyntaxFacts.Instance.EscapeIdentifier(parameterName)))
                    : node;
            }
        }

        static IEnumerable<XmlNodeSyntax> ConvertSummaryToRemarks(IEnumerable<XmlNodeSyntax> nodes)
        {
            foreach (var node in nodes)
            {
                yield return IsXmlElement(node, s_summaryTagName, out var xmlElement)
                    ? ConvertXmlElementName(xmlElement, s_remarksTagName)
                    : node;
            }
        }

        static SyntaxTriviaList MergeTypeDeclarationAndConstructorDocComments(
            TypeDeclarationSyntax typeDeclaration,
            ConstructorDeclarationSyntax constructorDeclaration)
        {
            var typeDeclarationLeadingTrivia = typeDeclaration.GetLeadingTrivia();

            // TODO: add support for `/** */` style doc comments if customer demand is there.
            var existingTypeDeclarationDocComment = GetDocComment(typeDeclarationLeadingTrivia);
            var existingConstructorDocComment = GetDocComment(constructorDeclaration);

            if (existingConstructorDocComment == default)
                return typeDeclarationLeadingTrivia;

            // If both type and the constructor have doc comments then merge them.  Otherwise, just move the constructor
            // docs to the type level.
            return InsertOrReplaceDocComments(
                typeDeclarationLeadingTrivia,
                existingTypeDeclarationDocComment == default
                    ? existingConstructorDocComment
                    : MergeDocComments(existingTypeDeclarationDocComment, existingConstructorDocComment));
        }

        static SyntaxTriviaList InsertOrReplaceDocComments(SyntaxTriviaList leadingTrivia, SyntaxTrivia newDocComment)
        {
            newDocComment = newDocComment.WithAdditionalAnnotations(Formatter.Annotation);

            var existingDocComment = GetDocComment(leadingTrivia);
            if (existingDocComment != default)
                return leadingTrivia.Replace(existingDocComment, newDocComment);

            // type doesn't have doc comment, but constructor does.  Move constructor doc comment to type decl.
            // note: the doc comment always ends with a newline.  so we want to place the new one before the
            // final leading spaces of the type decl trivia.
            var insertionIndex = leadingTrivia is [.., (kind: SyntaxKind.WhitespaceTrivia)]
                ? leadingTrivia.Count - 1
                : leadingTrivia.Count;

            return leadingTrivia.Insert(insertionIndex, newDocComment);
        }

        static SyntaxTrivia MergeDocComments(SyntaxTrivia typeDeclarationDocComment, SyntaxTrivia constructorDocComment)
        {
            var typeStructure = GetDocCommentStructure(typeDeclarationDocComment)!;
            var constructorStructure = GetDocCommentStructure(constructorDocComment)!;

            using var _ = ArrayBuilder<XmlNodeSyntax>.GetInstance(out var content);

            // Add all the type decl comments first.
            content.AddRange(typeStructure.Content);

            // then add the constructor comments.  If the type decl already had a summary tag then convert the
            // constructor's summary tag to a 'remarks' tag to keep around the info while not stomping on the
            // existing summary.
            var constructorContents = typeStructure.Content.Any(n => n is XmlElementSyntax { StartTag.Name.LocalName.ValueText: s_summaryTagName })
                ? ConvertSummaryToRemarks(constructorStructure.Content)
                : constructorStructure.Content;

            content.AddRange(constructorContents);

            return Trivia(DocumentationCommentTrivia(
                SyntaxKind.SingleLineDocumentationCommentTrivia,
                [.. content],
                typeStructure.EndOfComment));
        }

        static SyntaxTriviaList MergeTypeDeclarationAndRemovedMembersDocComments(
            IMethodSymbol constructor,
            ImmutableDictionary<string, string?> properties,
            ImmutableDictionary<ISymbol, (MemberDeclarationSyntax memberNode, SyntaxNode nodeToRemove)> removedMembers,
            SyntaxTriviaList typeDeclarationLeadingTrivia)
        {
            // now, if we're removing any members, and they had doc comments, and we don't already have doc comments for
            // that parameter in our final doc comment, then move them to there, converting from `<summary>` doc comments to
            // `<param name="x">` doc comments.

            // Keep the <param> tags ordered by the order they are in the constructor parameters.
            var orderedKVPs = properties.OrderBy(kvp => constructor.Parameters.FirstOrDefault(p => p.Name == kvp.Value)?.Ordinal);
            using var _1 = ArrayBuilder<(string parameterName, DocumentationCommentTriviaSyntax docComment)>.GetInstance(out var docCommentsToMove);
            foreach (var (memberName, parameterName) in orderedKVPs)
            {
                var (removedMember, (memberDeclaration, _)) = removedMembers.FirstOrDefault(kvp => kvp.Key.Name == memberName);
                if (removedMember is null)
                    continue;

                var removedMemberDocComment = GetDocCommentStructure(memberDeclaration);
                if (removedMemberDocComment != null)
                    docCommentsToMove.Add((parameterName, removedMemberDocComment)!);
            }

            if (docCommentsToMove.Count == 0)
                return typeDeclarationLeadingTrivia;

            using var _2 = PooledHashSet<string>.GetInstance(out var existingParamNodeNames);
            using var _3 = ArrayBuilder<XmlNodeSyntax>.GetInstance(out var allContent);

            var existingTypeDeclarationDocComment = GetDocCommentStructure(typeDeclarationLeadingTrivia);
            if (existingTypeDeclarationDocComment != null)
            {
                allContent.AddRange(existingTypeDeclarationDocComment.Content);

                foreach (var node in existingTypeDeclarationDocComment.Content)
                {
                    if (IsXmlElement(node, s_paramTagName, out var paramElement))
                    {
                        foreach (var attribute in paramElement.StartTag.Attributes)
                        {
                            if (attribute is XmlNameAttributeSyntax nameAttribute)
                                existingParamNodeNames.Add(nameAttribute.Identifier.Identifier.ValueText);
                        }
                    }
                }
            }

            foreach (var (parameterName, commentToMove) in docCommentsToMove)
            {
                if (!existingParamNodeNames.Contains(parameterName))
                    allContent.AddRange(ConvertSummaryToParam(commentToMove.Content, parameterName));
            }

            return InsertOrReplaceDocComments(
                typeDeclarationLeadingTrivia,
                Trivia(DocumentationCommentTrivia(SyntaxKind.SingleLineDocumentationCommentTrivia, [.. allContent])));
        }
    }
}