File: DocumentationComments\AbstractDocumentationCommentFormattingService.cs
Web Access
Project: ..\..\..\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.Text.RegularExpressions;
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 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 = new();
 
            /// <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 ImmutableArray<TaggedText>.Empty;
            }
            //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 object)
            {
                if (semanticModel is object)
                {
                    var symbol = DocumentationCommentId.GetFirstSymbolForDeclarationId(crefAttribute.Value, semanticModel.Compilation);
                    if (symbol is object)
                    {
                        return (target: SymbolKey.CreateString(symbol), hint: symbol.ToMinimalDisplayString(semanticModel, position, format ?? SymbolDisplayFormat.MinimallyQualifiedFormat));
                    }
                }
            }
 
            var hrefAttribute = element.Attribute(DocumentationCommentXmlNames.HrefAttributeName);
            if (hrefAttribute is object)
            {
                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(SpecializedCollections.SingletonEnumerable(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 (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 SpecializedCollections.SingletonEnumerable(
                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 SpecializedCollections.SingletonEnumerable(
                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();
            }
        }
    }
}