File: XmlComments\XmlComment.InheritDoc.cs
Web Access
Project: src\src\OpenApi\gen\Microsoft.AspNetCore.OpenApi.SourceGenerators.csproj (Microsoft.AspNetCore.OpenApi.SourceGenerators)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
using Microsoft.AspNetCore.Analyzers.Infrastructure;
using Microsoft.CodeAnalysis;
 
namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml;
 
/// <remarks>
/// Source code in this class is derived from Roslyn's Xml documentation comment processing
/// to support the full resolution of <inheritdoc /> tags.
/// For the original code, see https://github.com/dotnet/roslyn/blob/ef1d7fe925c94e96a93e4c9af50983e0f675a9fd/src/Workspaces/Core/Portable/Shared/Extensions/ISymbolExtensions.cs.
/// </remarks>
internal sealed partial class XmlComment
{
    private static string? GetDocumentationComment(ISymbol symbol, string xmlText, HashSet<ISymbol>? visitedSymbols, Compilation compilation, CancellationToken cancellationToken)
    {
        try
        {
            if (string.IsNullOrEmpty(xmlText))
            {
                if (IsEligibleForAutomaticInheritdoc(symbol))
                {
                    xmlText = $@"<doc><inheritdoc/></doc>";
                }
                else
                {
                    return string.Empty;
                }
            }
 
            try
            {
                var element = XElement.Parse(xmlText, LoadOptions.PreserveWhitespace);
                element.ReplaceNodes(RewriteMany(symbol, visitedSymbols, compilation, [.. element.Nodes()], cancellationToken));
                xmlText = element.ToString(SaveOptions.DisableFormatting);
            }
            catch (Exception)
            {
                return null;
            }
            return xmlText;
        }
        catch (XmlException)
        {
            return null;
        }
    }
 
    private static bool IsEligibleForAutomaticInheritdoc(ISymbol symbol)
    {
        // Only the following symbols are eligible to inherit documentation without an <inheritdoc/> element:
        //
        // * Members that override an inherited member
        // * Members that implement an interface member
        if (symbol.IsOverride)
        {
            return true;
        }
 
        if (symbol.ContainingType is null)
        {
            // Observed with certain implicit operators, such as operator==(void*, void*).
            return false;
        }
 
        switch (symbol.Kind)
        {
            case SymbolKind.Method:
            case SymbolKind.Property:
            case SymbolKind.Event:
                if (symbol.ExplicitOrImplicitInterfaceImplementations().Any())
                {
                    return true;
                }
 
                break;
 
            default:
                break;
        }
 
        return false;
    }
 
    private static XNode[] RewriteMany(ISymbol symbol, HashSet<ISymbol>? visitedSymbols, Compilation compilation, XNode[] nodes, CancellationToken cancellationToken)
    {
        var result = new List<XNode>();
        foreach (var child in nodes)
        {
            result.AddRange(RewriteInheritdocElements(symbol, visitedSymbols, compilation, child, cancellationToken));
        }
 
        return [.. result];
    }
 
    private static XNode[] RewriteInheritdocElements(ISymbol symbol, HashSet<ISymbol>? visitedSymbols, Compilation compilation, XNode node, CancellationToken cancellationToken)
    {
        if (node.NodeType == XmlNodeType.Element)
        {
            var element = (XElement)node;
            if (ElementNameIs(element, DocumentationCommentXmlNames.InheritdocElementName))
            {
                var rewritten = RewriteInheritdocElement(symbol, visitedSymbols, compilation, element, cancellationToken);
                if (rewritten is not null)
                {
                    return rewritten;
                }
            }
        }
 
        if (node is not XContainer container)
        {
            return [Copy(node, copyAttributeAnnotations: false)];
        }
 
        var oldNodes = container.Nodes();
 
        // Do this after grabbing the nodes, so we don't see copies of them.
        container = Copy(container, copyAttributeAnnotations: false);
 
        // WARN: don't use node after this point - use container since it's already been copied.
 
        if (oldNodes != null)
        {
            var rewritten = RewriteMany(symbol, visitedSymbols, compilation, [.. oldNodes], cancellationToken);
            container.ReplaceNodes(rewritten);
        }
 
        return [container];
    }
 
    private static XNode[]? RewriteInheritdocElement(ISymbol memberSymbol, HashSet<ISymbol>? visitedSymbols, Compilation compilation, XElement element, CancellationToken cancellationToken)
    {
        var crefAttribute = element.Attribute(XName.Get(DocumentationCommentXmlNames.CrefAttributeName));
        var pathAttribute = element.Attribute(XName.Get(DocumentationCommentXmlNames.PathAttributeName));
 
        var candidate = GetCandidateSymbol(memberSymbol);
        var hasCandidateCref = candidate is object;
 
        var hasCrefAttribute = crefAttribute is not null;
        var hasPathAttribute = pathAttribute is not null;
        if (!hasCrefAttribute && !hasCandidateCref)
        {
            // No cref available
            return null;
        }
 
        ISymbol? symbol;
        if (crefAttribute is null)
        {
            if (candidate is null)
            {
                return null;
            }
            symbol = candidate;
        }
        else
        {
            var crefValue = crefAttribute.Value;
            symbol = DocumentationCommentId.GetFirstSymbolForDeclarationId(crefValue, compilation);
            if (symbol is null)
            {
                return null;
            }
        }
 
        visitedSymbols ??= [];
        if (!visitedSymbols.Add(symbol!))
        {
            // Prevent recursion
            return null;
        }
 
        try
        {
            var xmlDocumentation = symbol.GetDocumentationCommentXml(cancellationToken: cancellationToken);
            if (xmlDocumentation is null)
            {
                return [];
            }
            var inheritedDocumentation = GetDocumentationComment(symbol, xmlDocumentation, visitedSymbols, compilation, cancellationToken);
            if (inheritedDocumentation == string.Empty)
            {
                return [];
            }
 
            var document = XDocument.Parse(inheritedDocumentation, LoadOptions.PreserveWhitespace);
            string xpathValue;
            if (string.IsNullOrEmpty(pathAttribute?.Value))
            {
                xpathValue = BuildXPathForElement(element.Parent!);
            }
            else
            {
                xpathValue = pathAttribute!.Value;
                if (xpathValue.StartsWith("/", StringComparison.InvariantCulture))
                {
                    // Account for the root <doc> or <member> element
                    xpathValue = "/*" + xpathValue;
                }
            }
 
            // Consider the following code, we want Test<int>.Clone to say "Clones a Test<int>" instead of "Clones a int", thus
            // we rewrite `typeparamref`s as cref pointing to the correct type:
            /*
                public class Test<T> : ICloneable<Test<T>>
                {
                    /// <inheritdoc/>
                    public Test<T> Clone() => new();
                }
 
                /// <summary>A type that has clonable instances.</summary>
                /// <typeparam name="T">The type of instances that can be cloned.</typeparam>
                public interface ICloneable<T>
                {
                    /// <summary>Clones a <typeparamref name="T"/>.</summary>
                    public T Clone();
                }
            */
            // Note: there is no way to cref an instantiated generic type. See https://github.com/dotnet/csharplang/issues/401
            var typeParameterRefs = document.Descendants(DocumentationCommentXmlNames.TypeParameterReferenceElementName).ToImmutableArray();
            foreach (var typeParameterRef in typeParameterRefs)
            {
                if (typeParameterRef.Attribute(DocumentationCommentXmlNames.NameAttributeName) is XAttribute typeParamName)
                {
                    var targetTypeParameter = symbol.GetAllTypeParameters().FirstOrDefault(p => p.Name == typeParamName.Value);
                    if (targetTypeParameter is not null
                        && symbol.OriginalDefinition.GetAllTypeParameters().IndexOf(targetTypeParameter) is int index
                        && index >= 0)
                    {
                        var typeArgs = symbol.GetAllTypeArguments();
                        if (index < typeArgs.Length)
                        {
                            var docId = typeArgs[index].GetDocumentationCommentId();
                            if (docId != null && !docId.StartsWith("!", StringComparison.OrdinalIgnoreCase))
                            {
                                var replacement = new XElement(DocumentationCommentXmlNames.SeeElementName);
                                replacement.SetAttributeValue(DocumentationCommentXmlNames.CrefAttributeName, docId);
                                typeParameterRef.ReplaceWith(replacement);
                            }
                        }
                    }
                }
            }
 
            var loadedElements = TrySelectNodes(document, xpathValue);
            return loadedElements ?? [];
        }
        catch (XmlException)
        {
            return [];
        }
        finally
        {
            visitedSymbols.Remove(symbol);
        }
 
        // Local functions
        static ISymbol? GetCandidateSymbol(ISymbol memberSymbol)
        {
            if (memberSymbol.ExplicitInterfaceImplementations().Any())
            {
                return memberSymbol.ExplicitInterfaceImplementations().First();
            }
            else if (memberSymbol.IsOverride)
            {
                return memberSymbol.GetOverriddenMember();
            }
 
            if (memberSymbol is IMethodSymbol methodSymbol)
            {
                if (methodSymbol.MethodKind is MethodKind.Constructor or MethodKind.StaticConstructor)
                {
                    var baseType = memberSymbol.ContainingType.BaseType;
#nullable disable // Can 'baseType' be null here? https://github.com/dotnet/roslyn/issues/39166
                    return baseType.Constructors.Where(c => IsSameSignature(methodSymbol, c)).FirstOrDefault();
#nullable enable
                }
                else
                {
                    // check for implicit interface
                    return methodSymbol.ExplicitOrImplicitInterfaceImplementations().FirstOrDefault();
                }
            }
            else if (memberSymbol is INamedTypeSymbol typeSymbol)
            {
                if (typeSymbol.TypeKind == TypeKind.Class)
                {
                    // Classes use the base type as the default inheritance candidate. A different target (e.g. an
                    // interface) can be provided via the 'path' attribute.
                    return typeSymbol.BaseType;
                }
                else if (typeSymbol.TypeKind == TypeKind.Interface)
                {
                    return typeSymbol.Interfaces.FirstOrDefault();
                }
                else
                {
                    // This includes structs, enums, and delegates as mentioned in the inheritdoc spec
                    return null;
                }
            }
 
            return memberSymbol.ExplicitOrImplicitInterfaceImplementations().FirstOrDefault();
        }
 
        static bool IsSameSignature(IMethodSymbol left, IMethodSymbol right)
        {
            if (left.Parameters.Length != right.Parameters.Length)
            {
                return false;
            }
 
            if (left.IsStatic != right.IsStatic)
            {
                return false;
            }
 
            if (!SymbolEqualityComparer.Default.Equals(left.ReturnType, right.ReturnType))
            {
                return false;
            }
 
            for (var i = 0; i < left.Parameters.Length; i++)
            {
                if (!SymbolEqualityComparer.Default.Equals(left.Parameters[i].Type, right.Parameters[i].Type))
                {
                    return false;
                }
            }
 
            return true;
        }
 
        static string BuildXPathForElement(XElement element)
        {
            if (ElementNameIs(element, "member") || ElementNameIs(element, "doc"))
            {
                // Avoid string concatenation allocations for inheritdoc as a top-level element
                return "/*/node()[not(self::overloads)]";
            }
 
            var path = "/node()[not(self::overloads)]";
            for (var current = element; current != null; current = current.Parent)
            {
                var currentName = current.Name.ToString();
                if (ElementNameIs(current, "member") || ElementNameIs(current, "doc"))
                {
                    // Allow <member> and <doc> to be used interchangeably
                    currentName = "*";
                }
 
                path = "/" + currentName + path;
            }
 
            return path;
        }
    }
 
    private static bool ElementNameIs(XElement element, string name)
        => string.IsNullOrEmpty(element.Name.NamespaceName) && DocumentationCommentXmlNames.ElementEquals(element.Name.LocalName, name);
 
    private static TNode Copy<TNode>(TNode node, bool copyAttributeAnnotations)
        where TNode : XNode
    {
        XNode copy;
 
        // Documents can't be added to containers, so our usual copy trick won't work.
        if (node.NodeType == XmlNodeType.Document)
        {
            copy = new XDocument((XDocument)(object)node);
        }
        else
        {
            XContainer temp = new XElement("temp");
            temp.Add(node);
            copy = temp.LastNode!;
            temp.RemoveNodes();
        }
 
        AnalyzerDebug.Assert(copy != node);
        AnalyzerDebug.Assert(copy.Parent == null); // Otherwise, when we give it one, it will be copied.
 
        // Copy annotations, the above doesn't preserve them.
        // We need to preserve Location annotations as well as line position annotations.
        CopyAnnotations(node, copy);
 
        // We also need to preserve line position annotations for all attributes
        // since we report errors with attribute locations.
        if (copyAttributeAnnotations && node.NodeType == XmlNodeType.Element)
        {
            var sourceElement = (XElement)(object)node;
            var targetElement = (XElement)copy;
 
            var sourceAttributes = sourceElement.Attributes().GetEnumerator();
            var targetAttributes = targetElement.Attributes().GetEnumerator();
            while (sourceAttributes.MoveNext() && targetAttributes.MoveNext())
            {
                AnalyzerDebug.Assert(sourceAttributes.Current.Name == targetAttributes.Current.Name);
                CopyAnnotations(sourceAttributes.Current, targetAttributes.Current);
            }
        }
 
        return (TNode)copy;
    }
 
    private static void CopyAnnotations(XObject source, XObject target)
    {
        foreach (var annotation in source.Annotations<object>())
        {
            target.AddAnnotation(annotation);
        }
    }
 
    private static XNode[]? TrySelectNodes(XNode node, string xpath)
    {
        try
        {
            var xpathResult = (IEnumerable)System.Xml.XPath.Extensions.XPathEvaluate(node, xpath);
 
            // Throws InvalidOperationException if the result of the XPath is an XDocument:
            return xpathResult?.Cast<XNode>().ToArray();
        }
        catch (InvalidOperationException)
        {
            return null;
        }
        catch (XPathException)
        {
            return null;
        }
    }
}