File: DocumentationComments\AbstractDocumentationCommentFormattingService.cs
Web Access
Project: src\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.
 
#nullable disable
 
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Threading;
using System.Xml;
using System.Xml.Linq;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.DocumentationComments;
 
internal abstract class AbstractDocumentationCommentFormattingService : IDocumentationCommentFormattingService
{
    private enum DocumentationCommentListType
    {
        None,
        Bullet,
        Number,
        Table,
    }
 
    private sealed class FormatterState
    {
        private bool _anyNonWhitespaceSinceLastPara;
        private bool _pendingParagraphBreak;
        private bool _pendingLineBreak;
        private bool _pendingSingleSpace;
 
        private static readonly TaggedText s_spacePart = new(TextTags.Space, " ");
        private static readonly TaggedText s_newlinePart = new(TextTags.LineBreak, "\r\n");
 
        internal readonly ImmutableArray<TaggedText>.Builder Builder = ImmutableArray.CreateBuilder<TaggedText>();
 
        /// <summary>
        /// Defines the containing lists for the current formatting state. The last item in the list is the
        /// innermost list.
        ///
        /// <list type="bullet">
        /// <item>
        /// <term><c>type</c></term>
        /// <description>The type of list.</description>
        /// </item>
        /// <item>
        /// <term><c>index</c></term>
        /// <description>The index of the current item in the list.</description>
        /// </item>
        /// <item>
        /// <term><c>renderedItem</c></term>
        /// <description><see langword="true"/> if the label (a bullet or number) for the current list item has already been rendered; otherwise <see langword="false"/>.</description>
        /// </item>
        /// </list>
        /// </summary>
        private readonly List<(DocumentationCommentListType type, int index, bool renderedItem)> _listStack = [];
 
        /// <summary>
        /// The top item of the stack indicates the hyperlink to apply to text rendered at the current location. It
        /// consists of a navigation <c>target</c> (the destination to navigate to when clicked) and a <c>hint</c>
        /// (typically shown as a tooltip for the link). This stack is never empty; when no hyperlink applies to the
        /// current scope, the top item of the stack will be a default tuple instance.
        /// </summary>
        private readonly Stack<(string target, string hint)> _navigationTargetStack = new();
 
        /// <summary>
        /// Tracks the style for text. The top item of the stack is the current style to apply (the merged result of
        /// all containing styles). This stack is never empty; when no style applies to the current scope, the top
        /// item of the stack will be <see cref="TaggedTextStyle.None"/>.
        /// </summary>
        private readonly Stack<TaggedTextStyle> _styleStack = new();
 
        public FormatterState()
        {
            _navigationTargetStack.Push(default);
            _styleStack.Push(TaggedTextStyle.None);
        }
 
        internal SemanticModel SemanticModel { get; set; }
        internal ISymbol TypeResolutionSymbol { get; set; }
        internal int Position { get; set; }
 
        public bool AtBeginning
        {
            get
            {
                return Builder.Count == 0;
            }
        }
 
        public SymbolDisplayFormat Format { get; internal set; }
 
        internal (string target, string hint) NavigationTarget => _navigationTargetStack.Peek();
        internal TaggedTextStyle Style => _styleStack.Peek();
 
        public void AppendSingleSpace()
            => _pendingSingleSpace = true;
 
        public void AppendString(string s)
        {
            EmitPendingChars();
            Builder.Add(new TaggedText(TextTags.Text, NormalizeLineEndings(s), Style, NavigationTarget.target, NavigationTarget.hint));
 
            _anyNonWhitespaceSinceLastPara = true;
 
            // XText.Value returns a string with `\n` as the line endings, causing
            // the end result to have mixed line-endings. So normalize everything to `\r\n`.
            // https://www.w3.org/TR/xml/#sec-line-ends
            static string NormalizeLineEndings(string input) => input.Replace("\n", "\r\n");
        }
 
        public void AppendParts(IEnumerable<TaggedText> parts)
        {
            EmitPendingChars();
 
            Builder.AddRange(parts);
 
            _anyNonWhitespaceSinceLastPara = true;
        }
 
        public void PushList(DocumentationCommentListType listType)
        {
            _listStack.Add((listType, index: 0, renderedItem: false));
            MarkBeginOrEndPara();
        }
 
        /// <summary>
        /// Marks the start of an item in a list; called before each item.
        /// </summary>
        public void NextListItem()
        {
            if (_listStack.Count == 0)
            {
                return;
            }
 
            var (type, index, renderedItem) = _listStack[^1];
            if (renderedItem)
            {
                // Mark the end of the previous list item
                Builder.Add(new TaggedText(TextTags.ContainerEnd, string.Empty));
            }
 
            // The next list item has an incremented index, and has not yet been rendered to Builder.
            _listStack[^1] = (type, index + 1, renderedItem: false);
            MarkLineBreak();
        }
 
        public void PopList()
        {
            if (_listStack.Count == 0)
            {
                return;
            }
 
            if (_listStack[^1].renderedItem)
            {
                Builder.Add(new TaggedText(TextTags.ContainerEnd, string.Empty));
            }
 
            _listStack.RemoveAt(_listStack.Count - 1);
            MarkBeginOrEndPara();
        }
 
        public void PushNavigationTarget(string target, string hint)
            => _navigationTargetStack.Push((target, hint));
 
        public void PopNavigationTarget()
            => _navigationTargetStack.Pop();
 
        public void PushStyle(TaggedTextStyle style)
            => _styleStack.Push(_styleStack.Peek() | style);
 
        public void PopStyle()
            => _styleStack.Pop();
 
        public void MarkBeginOrEndPara()
        {
            // If this is a <para> with nothing before it, then skip it.
            if (_anyNonWhitespaceSinceLastPara == false)
            {
                return;
            }
 
            _pendingParagraphBreak = true;
 
            // Reset flag.
            _anyNonWhitespaceSinceLastPara = false;
        }
 
        public void MarkLineBreak()
        {
            // If this is a <br> with nothing before it, then skip it.
            if (_anyNonWhitespaceSinceLastPara == false && !_pendingLineBreak)
            {
                return;
            }
 
            if (_pendingLineBreak || _pendingParagraphBreak)
            {
                // Multiple line breaks in sequence become a single paragraph break.
                _pendingParagraphBreak = true;
                _pendingLineBreak = false;
            }
            else
            {
                _pendingLineBreak = true;
            }
 
            // Reset flag.
            _anyNonWhitespaceSinceLastPara = false;
        }
 
        public string GetText()
            => Builder.GetFullText();
 
        private void EmitPendingChars()
        {
            if (_pendingParagraphBreak)
            {
                Builder.Add(s_newlinePart);
                Builder.Add(s_newlinePart);
            }
            else if (_pendingLineBreak)
            {
                Builder.Add(s_newlinePart);
            }
            else if (_pendingSingleSpace)
            {
                Builder.Add(s_spacePart);
            }
 
            _pendingParagraphBreak = false;
            _pendingLineBreak = false;
            _pendingSingleSpace = false;
 
            for (var i = 0; i < _listStack.Count; i++)
            {
                if (_listStack[i].renderedItem)
                {
                    continue;
                }
 
                switch (_listStack[i].type)
                {
                    case DocumentationCommentListType.Bullet:
                        Builder.Add(new TaggedText(TextTags.ContainerStart, "• "));
                        break;
 
                    case DocumentationCommentListType.Number:
                        Builder.Add(new TaggedText(TextTags.ContainerStart, $"{_listStack[i].index}. "));
                        break;
 
                    case DocumentationCommentListType.Table:
                    case DocumentationCommentListType.None:
                    default:
                        Builder.Add(new TaggedText(TextTags.ContainerStart, string.Empty));
                        break;
                }
 
                _listStack[i] = (_listStack[i].type, _listStack[i].index, renderedItem: true);
            }
        }
    }
 
    public string Format(string rawXmlText, Compilation compilation = null)
    {
        if (rawXmlText == null)
        {
            return null;
        }
 
        var state = new FormatterState();
 
        // In case the XML is a fragment (that is, a series of elements without a parent)
        // wrap it up in a single tag. This makes parsing it much, much easier.
        var inputString = "<tag>" + rawXmlText + "</tag>";
 
        var summaryElement = XElement.Parse(inputString, LoadOptions.PreserveWhitespace);
 
        AppendTextFromNode(state, summaryElement, compilation);
 
        return state.GetText();
    }
 
    public ImmutableArray<TaggedText> Format(string rawXmlText, ISymbol symbol, SemanticModel semanticModel, int position, SymbolDisplayFormat format, CancellationToken cancellationToken)
    {
        if (rawXmlText is null)
        {
            return [];
        }
        //symbol = symbol.OriginalDefinition;
 
        var state = new FormatterState() { SemanticModel = semanticModel, Position = position, Format = format, TypeResolutionSymbol = symbol };
 
        // In case the XML is a fragment (that is, a series of elements without a parent)
        // wrap it up in a single tag. This makes parsing it much, much easier.
        var inputString = "<tag>" + rawXmlText + "</tag>";
 
        var summaryElement = XElement.Parse(inputString, LoadOptions.PreserveWhitespace);
 
        AppendTextFromNode(state, summaryElement, state.SemanticModel.Compilation);
 
        return state.Builder.ToImmutable();
    }
 
    private static void AppendTextFromNode(FormatterState state, XNode node, Compilation compilation)
    {
        if (node.NodeType is XmlNodeType.Text or XmlNodeType.CDATA)
        {
            // cast is safe since XCData inherits XText
            AppendTextFromTextNode(state, (XText)node);
        }
 
        if (node.NodeType != XmlNodeType.Element)
        {
            return;
        }
 
        var element = (XElement)node;
 
        var name = element.Name.LocalName;
        var needPopStyle = false;
        (string target, string hint)? navigationTarget = null;
 
        if (name is DocumentationCommentXmlNames.SeeElementName or
            DocumentationCommentXmlNames.SeeAlsoElementName or
            "a")
        {
            if (element.IsEmpty || element.FirstNode == null)
            {
                foreach (var attribute in element.Attributes())
                {
                    AppendTextFromAttribute(state, attribute, attributeNameToParse: DocumentationCommentXmlNames.CrefAttributeName, SymbolDisplayPartKind.Text);
                }
 
                return;
            }
            else
            {
                navigationTarget = GetNavigationTarget(element, state.SemanticModel, state.Position, state.Format);
                if (navigationTarget is object)
                {
                    state.PushNavigationTarget(navigationTarget.Value.target, navigationTarget.Value.hint);
                }
            }
        }
        else if (name is DocumentationCommentXmlNames.ParameterReferenceElementName or
                 DocumentationCommentXmlNames.TypeParameterReferenceElementName)
        {
            var kind = name == DocumentationCommentXmlNames.ParameterReferenceElementName ? SymbolDisplayPartKind.ParameterName : SymbolDisplayPartKind.TypeParameterName;
            foreach (var attribute in element.Attributes())
            {
                AppendTextFromAttribute(state, attribute, attributeNameToParse: DocumentationCommentXmlNames.NameAttributeName, kind);
            }
 
            return;
        }
        else if (name is DocumentationCommentXmlNames.CElementName or "tt")
        {
            needPopStyle = true;
            state.PushStyle(TaggedTextStyle.Code);
        }
        else if (name is DocumentationCommentXmlNames.CodeElementName)
        {
            needPopStyle = true;
            state.PushStyle(TaggedTextStyle.Code | TaggedTextStyle.PreserveWhitespace);
        }
        else if (name is "em" or "i")
        {
            needPopStyle = true;
            state.PushStyle(TaggedTextStyle.Emphasis);
        }
        else if (name is "strong" or "b" or DocumentationCommentXmlNames.TermElementName)
        {
            needPopStyle = true;
            state.PushStyle(TaggedTextStyle.Strong);
        }
        else if (name == "u")
        {
            needPopStyle = true;
            state.PushStyle(TaggedTextStyle.Underline);
        }
 
        if (name == DocumentationCommentXmlNames.ListElementName)
        {
            var rawListType = element.Attribute(DocumentationCommentXmlNames.TypeAttributeName)?.Value;
            var listType = rawListType switch
            {
                "table" => DocumentationCommentListType.Table,
                "number" => DocumentationCommentListType.Number,
                "bullet" => DocumentationCommentListType.Bullet,
                _ => DocumentationCommentListType.None,
            };
            state.PushList(listType);
        }
        else if (name == DocumentationCommentXmlNames.ItemElementName)
        {
            state.NextListItem();
        }
 
        if (name is DocumentationCommentXmlNames.ParaElementName
            or DocumentationCommentXmlNames.CodeElementName)
        {
            state.MarkBeginOrEndPara();
        }
        else if (name == "br")
        {
            state.MarkLineBreak();
        }
 
        foreach (var childNode in element.Nodes())
        {
            AppendTextFromNode(state, childNode, compilation);
        }
 
        if (name is DocumentationCommentXmlNames.ParaElementName
            or DocumentationCommentXmlNames.CodeElementName)
        {
            state.MarkBeginOrEndPara();
        }
 
        if (name == DocumentationCommentXmlNames.ListElementName)
        {
            state.PopList();
        }
 
        if (needPopStyle)
        {
            state.PopStyle();
        }
 
        if (navigationTarget is object)
        {
            state.PopNavigationTarget();
        }
 
        if (name == DocumentationCommentXmlNames.TermElementName)
        {
            state.AppendSingleSpace();
            state.AppendString("–");
        }
    }
 
    private static (string target, string hint)? GetNavigationTarget(XElement element, SemanticModel semanticModel, int position, SymbolDisplayFormat format)
    {
        var crefAttribute = element.Attribute(DocumentationCommentXmlNames.CrefAttributeName);
        if (crefAttribute is not null && semanticModel is not null)
        {
            var symbol = DocumentationCommentId.GetFirstSymbolForDeclarationId(crefAttribute.Value, semanticModel.Compilation);
            if (symbol is not null)
                return (target: SymbolKey.CreateString(symbol), hint: symbol.ToMinimalDisplayString(semanticModel, position, format ?? SymbolDisplayFormat.MinimallyQualifiedFormat));
        }
 
        var hrefAttribute = element.Attribute(DocumentationCommentXmlNames.HrefAttributeName);
        if (hrefAttribute is not null)
            return (target: hrefAttribute.Value, hint: hrefAttribute.Value);
 
        return null;
    }
 
    private static void AppendTextFromAttribute(FormatterState state, XAttribute attribute, string attributeNameToParse, SymbolDisplayPartKind kind)
    {
        var attributeName = attribute.Name.LocalName;
        if (attributeNameToParse == attributeName)
        {
            if (kind == SymbolDisplayPartKind.TypeParameterName)
            {
                state.AppendParts(
                    TypeParameterRefToSymbolDisplayParts(attribute.Value, state.TypeResolutionSymbol, state.Position, state.SemanticModel, state.Format).ToTaggedText(state.Style));
            }
            else
            {
                state.AppendParts(
                    CrefToSymbolDisplayParts(attribute.Value, state.Position, state.SemanticModel, state.Format, kind).ToTaggedText(state.Style));
            }
        }
        else
        {
            var displayKind = attributeName == DocumentationCommentXmlNames.LangwordAttributeName
                ? TextTags.Keyword
                : TextTags.Text;
            var text = attribute.Value;
            var style = state.Style;
            var navigationTarget = attributeName == DocumentationCommentXmlNames.HrefAttributeName
                ? attribute.Value
                : null;
            var navigationHint = navigationTarget;
            state.AppendParts([new TaggedText(displayKind, text, style, navigationTarget, navigationHint)]);
        }
    }
 
    internal static IEnumerable<SymbolDisplayPart> CrefToSymbolDisplayParts(
        string crefValue, int position, SemanticModel semanticModel, SymbolDisplayFormat format = null, SymbolDisplayPartKind kind = SymbolDisplayPartKind.Text)
    {
        // first try to parse the symbol
        if (crefValue != null && semanticModel != null)
        {
            var symbol = DocumentationCommentId.GetFirstSymbolForDeclarationId(crefValue, semanticModel.Compilation);
            if (symbol != null)
            {
                format ??= SymbolDisplayFormat.MinimallyQualifiedFormat;
                if (symbol.IsConstructor())
                {
                    format = format.WithMemberOptions(SymbolDisplayMemberOptions.IncludeParameters | SymbolDisplayMemberOptions.IncludeExplicitInterface);
                }
 
                return symbol.ToMinimalDisplayParts(semanticModel, position, format);
            }
        }
 
        // if any of that fails fall back to just displaying the raw text
        return [new SymbolDisplayPart(kind, symbol: null, text: TrimCrefPrefix(crefValue))];
    }
 
    internal static IEnumerable<SymbolDisplayPart> TypeParameterRefToSymbolDisplayParts(
        string crefValue, ISymbol typeResolutionSymbol, int position, SemanticModel semanticModel, SymbolDisplayFormat format)
    {
        if (semanticModel != null)
        {
            var typeParameterIndex = typeResolutionSymbol.OriginalDefinition.GetAllTypeParameters().IndexOf(tp => tp.Name == crefValue);
            if (typeParameterIndex >= 0)
            {
                var typeArgs = typeResolutionSymbol.GetAllTypeArguments();
                if (typeArgs.Length > typeParameterIndex)
                {
                    return typeArgs[typeParameterIndex].ToMinimalDisplayParts(semanticModel, position, format);
                }
            }
        }
 
        // if any of that fails fall back to just displaying the raw text
        return [new SymbolDisplayPart(SymbolDisplayPartKind.TypeParameterName, symbol: null, text: TrimCrefPrefix(crefValue))];
    }
 
    private static string TrimCrefPrefix(string value)
    {
        if (value is [_, ':', ..])
            value = value[2..];
 
        return value;
    }
 
    private static void AppendTextFromTextNode(FormatterState state, XText element)
    {
        var rawText = element.Value;
        if ((state.Style & TaggedTextStyle.PreserveWhitespace) == TaggedTextStyle.PreserveWhitespace)
        {
            // Don't normalize code from middle. Only trim leading/trailing new lines.
            state.AppendString(rawText.Trim('\n'));
            return;
        }
 
        var builder = new StringBuilder(rawText.Length);
 
        // Normalize the whitespace.
        var pendingWhitespace = false;
        var hadAnyNonWhitespace = false;
        for (var i = 0; i < rawText.Length; i++)
        {
            if (char.IsWhiteSpace(rawText[i]))
            {
                // Whitespace. If it occurs at the beginning of the text we don't append it
                // at all; otherwise, we reduce it to a single space.
                if (!state.AtBeginning || hadAnyNonWhitespace)
                {
                    pendingWhitespace = true;
                }
            }
            else
            {
                // Some other character...
                if (pendingWhitespace)
                {
                    if (builder.Length == 0)
                    {
                        state.AppendSingleSpace();
                    }
                    else
                    {
                        builder.Append(' ');
                    }
 
                    pendingWhitespace = false;
                }
 
                builder.Append(rawText[i]);
                hadAnyNonWhitespace = true;
            }
        }
 
        if (builder.Length > 0)
        {
            state.AppendString(builder.ToString());
        }
 
        if (pendingWhitespace)
        {
            state.AppendSingleSpace();
        }
    }
}