File: src\Analyzers\CSharp\CodeFixes\UsePrimaryConstructor\CSharpUsePrimaryConstructorCodeFixProvider_DocComments.cs
Web Access
Project: src\src\Features\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Features.csproj (Microsoft.CodeAnalysis.CSharp.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.
 
// 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])));
        }
    }
}