File: Completion\Providers\AbstractDocCommentCompletionProvider.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.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Completion.Providers;
 
using static DocumentationCommentXmlNames;
 
internal abstract class AbstractDocCommentCompletionProvider<TSyntax> : LSPCompletionProvider
    where TSyntax : SyntaxNode
{
    // Tag names
    private static readonly ImmutableArray<string> s_listTagNames = [ListHeaderElementName, TermElementName, ItemElementName, DescriptionElementName];
    private static readonly ImmutableArray<string> s_listHeaderTagNames = [TermElementName, DescriptionElementName];
    private static readonly ImmutableArray<string> s_nestedTagNames = [CElementName, CodeElementName, ParaElementName, ListElementName];
    private static readonly ImmutableArray<string> s_topLevelRepeatableTagNames = [ExceptionElementName, IncludeElementName, PermissionElementName];
    private static readonly ImmutableArray<string> s_topLevelSingleUseTagNames = [SummaryElementName, RemarksElementName, ExampleElementName, CompletionListElementName];
 
    private static readonly Dictionary<string, (string tagOpen, string textBeforeCaret, string textAfterCaret, string? tagClose)> s_tagMap =
        new Dictionary<string, (string tagOpen, string textBeforeCaret, string textAfterCaret, string? tagClose)>()
        {
            //                                        tagOpen                                  textBeforeCaret       $$  textAfterCaret                            tagClose
            { ExceptionElementName,              ($"<{ExceptionElementName}",              $" {CrefAttributeName}=\"",  "\"",                                      null) },
            { IncludeElementName,                ($"<{IncludeElementName}",                $" {FileAttributeName}=\'", $"\' {PathAttributeName}=\'[@name=\"\"]\'", "/>") },
            { InheritdocElementName,             ($"<{InheritdocElementName}",             $"",                         "",                                        "/>") },
            { PermissionElementName,             ($"<{PermissionElementName}",             $" {CrefAttributeName}=\"",  "\"",                                      null) },
            { SeeElementName,                    ($"<{SeeElementName}",                    $" {CrefAttributeName}=\"",  "\"",                                      "/>") },
            { SeeAlsoElementName,                ($"<{SeeAlsoElementName}",                $" {CrefAttributeName}=\"",  "\"",                                      "/>") },
            { ListElementName,                   ($"<{ListElementName}",                   $" {TypeAttributeName}=\"",  "\"",                                      null) },
            { ParameterReferenceElementName,     ($"<{ParameterReferenceElementName}",     $" {NameAttributeName}=\"",  "\"",                                      "/>") },
            { TypeParameterReferenceElementName, ($"<{TypeParameterReferenceElementName}", $" {NameAttributeName}=\"",  "\"",                                      "/>") },
            { CompletionListElementName,         ($"<{CompletionListElementName}",         $" {CrefAttributeName}=\"",  "\"",                                      "/>") },
        };
 
    private static readonly ImmutableArray<(string elementName, string attributeName, string text)> s_attributeMap =
        [
            (ExceptionElementName, CrefAttributeName, $"{CrefAttributeName}=\""),
            (PermissionElementName, CrefAttributeName, $"{CrefAttributeName}=\""),
            (SeeElementName, CrefAttributeName, $"{CrefAttributeName}=\""),
            (SeeElementName, LangwordAttributeName, $"{LangwordAttributeName}=\""),
            (SeeElementName, HrefAttributeName, $"{HrefAttributeName}=\""),
            (SeeAlsoElementName, CrefAttributeName, $"{CrefAttributeName}=\""),
            (SeeAlsoElementName, HrefAttributeName, $"{HrefAttributeName}=\""),
            (ListElementName, TypeAttributeName, $"{TypeAttributeName}=\""),
            (ParameterElementName, NameAttributeName, $"{NameAttributeName}=\""),
            (ParameterReferenceElementName, NameAttributeName, $"{NameAttributeName}=\""),
            (TypeParameterElementName, NameAttributeName, $"{NameAttributeName}=\""),
            (TypeParameterReferenceElementName, NameAttributeName, $"{NameAttributeName}=\""),
            (IncludeElementName, FileAttributeName, $"{FileAttributeName}=\""),
            (IncludeElementName, PathAttributeName, $"{PathAttributeName}=\""),
            (InheritdocElementName, CrefAttributeName, $"{CrefAttributeName}=\""),
            (InheritdocElementName, PathAttributeName, $"{PathAttributeName}=\""),
        ];
 
    private static readonly ImmutableArray<string> s_listTypeValues = ["bullet", "number", "table"];
 
    private readonly CompletionItemRules defaultRules;
 
    protected AbstractDocCommentCompletionProvider(CompletionItemRules defaultRules)
    {
        this.defaultRules = defaultRules ?? throw new ArgumentNullException(nameof(defaultRules));
    }
 
    public override async Task ProvideCompletionsAsync(CompletionContext context)
    {
        if (!context.CompletionOptions.ShowXmlDocCommentCompletion)
        {
            return;
        }
 
        var items = await GetItemsWorkerAsync(
            context.Document, context.Position, context.Trigger, context.CancellationToken).ConfigureAwait(false);
 
        if (items != null)
        {
            context.AddItems(items);
        }
    }
 
    protected abstract Task<IEnumerable<CompletionItem>?> GetItemsWorkerAsync(Document document, int position, CompletionTrigger trigger, CancellationToken cancellationToken);
 
    protected abstract IEnumerable<string> GetExistingTopLevelElementNames(TSyntax syntax);
 
    protected abstract IEnumerable<string?> GetExistingTopLevelAttributeValues(TSyntax syntax, string tagName, string attributeName);
 
    protected abstract ImmutableArray<string> GetKeywordNames();
 
    /// <summary>
    /// A temporarily hack that should be removed once/if https://github.com/dotnet/roslyn/issues/53092 is fixed.
    /// </summary>
    protected abstract ImmutableArray<IParameterSymbol> GetParameters(ISymbol symbol);
 
    private CompletionItem GetItem(string name)
    {
        if (s_tagMap.TryGetValue(name, out var values))
        {
            return CreateCompletionItem(name,
                beforeCaretText: values.tagOpen + values.textBeforeCaret,
                afterCaretText: values.textAfterCaret + values.tagClose);
        }
 
        return CreateCompletionItem(name);
    }
 
    protected IEnumerable<CompletionItem> GetAttributeItems(string tagName, ISet<string> existingAttributes, bool addEqualsAndQuotes)
    {
        return s_attributeMap
            .Where(x => x.elementName == tagName && !existingAttributes.Contains(x.attributeName))
            .Select(x => CreateCompletionItem(
                x.attributeName,
                beforeCaretText: addEqualsAndQuotes ? x.text : x.text[..^2],
                afterCaretText: addEqualsAndQuotes ? "\"" : ""));
    }
 
    protected IEnumerable<CompletionItem> GetAlwaysVisibleItems()
        => [GetCDataItem(), GetCommentItem(), GetItem(InheritdocElementName), GetItem(SeeElementName), GetItem(SeeAlsoElementName)];
 
    private CompletionItem GetCommentItem()
    {
        const string prefix = "!--";
        const string suffix = "-->";
        return CreateCompletionItem(prefix, beforeCaretText: "<" + prefix, afterCaretText: suffix);
    }
 
    private CompletionItem GetCDataItem()
    {
        const string prefix = "![CDATA[";
        const string suffix = "]]>";
        return CreateCompletionItem(prefix, beforeCaretText: "<" + prefix, afterCaretText: suffix);
    }
 
    protected IEnumerable<CompletionItem> GetNestedItems(ISymbol? symbol, bool includeKeywords)
    {
        var items = s_nestedTagNames.Select(GetItem);
 
        if (symbol != null)
        {
            items = items.Concat(GetParamRefItems(symbol))
                         .Concat(GetTypeParamRefItems(symbol));
        }
 
        if (includeKeywords)
        {
            items = items.Concat(GetKeywordNames().Select(CreateLangwordCompletionItem));
        }
 
        return items;
    }
 
    private IEnumerable<CompletionItem> GetParamRefItems(ISymbol symbol)
    {
        var names = GetParameters(symbol).Select(p => p.Name);
 
        return names.Select(p => CreateCompletionItem(
            displayText: FormatParameter(ParameterReferenceElementName, p),
            beforeCaretText: FormatParameterRefTag(ParameterReferenceElementName, p),
            afterCaretText: string.Empty));
    }
 
    private IEnumerable<CompletionItem> GetTypeParamRefItems(ISymbol symbol)
    {
        var names = symbol.GetAllTypeParameters().Select(t => t.Name);
 
        return names.Select(t => CreateCompletionItem(
            displayText: FormatParameter(TypeParameterReferenceElementName, t),
            beforeCaretText: FormatParameterRefTag(TypeParameterReferenceElementName, t),
            afterCaretText: string.Empty));
    }
 
    protected IEnumerable<CompletionItem> GetAttributeValueItems(ISymbol? symbol, string tagName, string attributeName)
    {
        if (attributeName == NameAttributeName && symbol != null)
        {
            if (tagName is ParameterElementName or ParameterReferenceElementName)
            {
                return GetParameters(symbol)
                             .Select(parameter => CreateCompletionItem(parameter.Name));
            }
            else if (tagName == TypeParameterElementName)
            {
                return symbol.GetTypeParameters()
                             .Select(typeParameter => CreateCompletionItem(typeParameter.Name));
            }
            else if (tagName == TypeParameterReferenceElementName)
            {
                return symbol.GetAllTypeParameters()
                             .Select(typeParameter => CreateCompletionItem(typeParameter.Name));
            }
        }
        else if (attributeName == LangwordAttributeName && tagName == SeeElementName)
        {
            return GetKeywordNames().Select(CreateCompletionItem);
        }
        else if (attributeName == TypeAttributeName && tagName == ListElementName)
        {
            return s_listTypeValues.Select(CreateCompletionItem);
        }
 
        return [];
    }
 
    protected ImmutableArray<CompletionItem> GetTopLevelItems(ISymbol? symbol, TSyntax syntax)
    {
        using var _1 = ArrayBuilder<CompletionItem>.GetInstance(out var items);
        using var _2 = PooledHashSet<string>.GetInstance(out var existingTopLevelTags);
 
        existingTopLevelTags.AddAll(GetExistingTopLevelElementNames(syntax));
 
        items.AddRange(s_topLevelSingleUseTagNames.Except(existingTopLevelTags).Select(GetItem));
        items.AddRange(s_topLevelRepeatableTagNames.Select(GetItem));
 
        if (symbol != null)
        {
            items.AddRange(GetParameterItems(GetParameters(symbol), syntax, ParameterElementName));
            items.AddRange(GetParameterItems(symbol.GetTypeParameters(), syntax, TypeParameterElementName));
 
            if (symbol is IPropertySymbol && !existingTopLevelTags.Contains(ValueElementName))
            {
                items.Add(GetItem(ValueElementName));
            }
 
            var returns = symbol is IMethodSymbol method && !method.ReturnsVoid;
            if (returns && !existingTopLevelTags.Contains(ReturnsElementName))
            {
                items.Add(GetItem(ReturnsElementName));
            }
 
            if (symbol is INamedTypeSymbol namedType && namedType.IsDelegateType())
            {
                var delegateInvokeMethod = namedType.DelegateInvokeMethod;
                if (delegateInvokeMethod != null)
                {
                    items.AddRange(GetParameterItems(delegateInvokeMethod.GetParameters(), syntax, ParameterElementName));
                }
            }
        }
 
        return items.ToImmutableAndClear();
    }
 
    protected IEnumerable<CompletionItem> GetItemTagItems()
        => new[] { TermElementName, DescriptionElementName }.Select(GetItem);
 
    protected IEnumerable<CompletionItem> GetListItems()
        => s_listTagNames.Select(GetItem);
 
    protected IEnumerable<CompletionItem> GetListHeaderItems()
        => s_listHeaderTagNames.Select(GetItem);
 
    private IEnumerable<CompletionItem> GetParameterItems<TSymbol>(ImmutableArray<TSymbol> symbols, TSyntax syntax, string tagName) where TSymbol : ISymbol
    {
        var names = symbols.Select(p => p.Name).ToSet();
        names.RemoveAll(GetExistingTopLevelAttributeValues(syntax, tagName, NameAttributeName).WhereNotNull());
        return names.Select(name => CreateCompletionItem(FormatParameter(tagName, name)));
    }
 
    private static string FormatParameter(string kind, string name)
        => $"{kind} {NameAttributeName}=\"{name}\"";
 
    private static string FormatParameterRefTag(string kind, string name)
        => $"<{kind} {NameAttributeName}=\"{name}\"/>";
 
    public override async Task<CompletionChange> GetChangeAsync(Document document, CompletionItem item, char? commitChar = null, CancellationToken cancellationToken = default)
    {
        var beforeCaretText = XmlDocCommentCompletionItem.GetBeforeCaretText(item);
        var afterCaretText = XmlDocCommentCompletionItem.GetAfterCaretText(item);
        var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
 
        var itemSpan = item.Span;
        var replacementSpan = TextSpan.FromBounds(text[itemSpan.Start - 1] == '<' && beforeCaretText[0] == '<' ? itemSpan.Start - 1 : itemSpan.Start, itemSpan.End);
 
        var replacementText = beforeCaretText;
        var newPosition = replacementSpan.Start + beforeCaretText.Length;
 
        if (text.Length > replacementSpan.End + 1
            && text[replacementSpan.End] == '='
            && text[replacementSpan.End + 1] == '"')
        {
            newPosition += 2;
        }
 
        if (commitChar.HasValue && !char.IsWhiteSpace(commitChar.Value) && commitChar.Value != replacementText[^1])
        {
            // include the commit character
            replacementText += commitChar.Value;
 
            // The caret goes after whatever commit character we spit.
            newPosition++;
        }
 
        replacementText += afterCaretText;
 
        return CompletionChange.Create(
            new TextChange(replacementSpan, replacementText),
            newPosition, includesCommitCharacter: true);
    }
 
    private CompletionItem CreateCompletionItem(string displayText)
    {
        return CreateCompletionItem(
            displayText: displayText,
            beforeCaretText: displayText,
            afterCaretText: string.Empty);
    }
 
    private CompletionItem CreateLangwordCompletionItem(string displayText)
    {
        return CreateCompletionItem(
            displayText: displayText,
            beforeCaretText: "<see langword=\"" + displayText + "\"/>",
            afterCaretText: string.Empty);
    }
 
    protected CompletionItem CreateCompletionItem(string displayText, string beforeCaretText, string afterCaretText)
        => XmlDocCommentCompletionItem.Create(displayText, beforeCaretText, afterCaretText, rules: GetCompletionItemRules(displayText));
 
    private static readonly CharacterSetModificationRule WithoutQuoteRule = CharacterSetModificationRule.Create(CharacterSetModificationKind.Remove, '"');
    private static readonly CharacterSetModificationRule WithoutSpaceRule = CharacterSetModificationRule.Create(CharacterSetModificationKind.Remove, ' ');
 
    protected static readonly ImmutableArray<CharacterSetModificationRule> FilterRules = [CharacterSetModificationRule.Create(CharacterSetModificationKind.Add, '!', '-', '[')];
 
    private CompletionItemRules GetCompletionItemRules(string displayText)
    {
        var commitRules = defaultRules.CommitCharacterRules;
 
        if (displayText.Contains("\""))
        {
            commitRules = commitRules.Add(WithoutQuoteRule);
        }
 
        if (displayText.Contains(" "))
        {
            commitRules = commitRules.Add(WithoutSpaceRule);
        }
 
        return defaultRules.WithCommitCharacterRules(commitRules);
    }
}