File: Completion\DirectiveAttributeCompletionItemProvider.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// 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.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.Tooltip;
using Microsoft.VisualStudio.Editor.Razor;
using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode;
 
namespace Microsoft.CodeAnalysis.Razor.Completion;
 
internal partial class DirectiveAttributeCompletionItemProvider : DirectiveAttributeCompletionItemProviderBase
{
    private const string Ellipsis = "...";
    private const string QuotedAttributeValueSnippetSuffix = "=\"$0\"";
    private const string UnquotedAttributeValueSnippetSuffix = "=$0";
 
    public override ImmutableArray<RazorCompletionItem> GetCompletionItems(RazorCompletionContext context)
    {
        if (!context.SyntaxTree.Options.FileKind.IsComponent())
        {
            // Directive attributes are only supported in components
            return [];
        }
 
        var owner = context.Owner;
        if (owner is null)
        {
            return [];
        }
 
        if (!TryGetAttributeInfo(owner, out _, out var attributeName, out var attributeNameLocation, out var parameterName, out var parameterNameLocation))
        {
            // Either we're not in an attribute or the attribute is so malformed that we can't provide proper completions.
            return [];
        }
 
        if (!TryGetElementInfo(owner.Parent.Parent, out var containingTagName, out var attributes))
        {
            // This should never be the case, it means that we're operating on an attribute that doesn't have a tag.
            return [];
        }
 
        // We don't provide Directive Attribute completions when we're in the middle of
        // another unrelated (doesn't start with @) partially completed attribute.
        // <svg xml:| ></svg> (attributeName = "xml:") should not get any directive attribute completions.
        if (!attributeName.IsNullOrWhiteSpace() && !attributeName.StartsWith('@'))
        {
            return [];
        }
 
        var isAttributeRequest = attributeNameLocation.IntersectsWith(context.AbsoluteIndex);
        var isParameterRequest = parameterNameLocation.IntersectsWith(context.AbsoluteIndex);
 
        if (!isAttributeRequest && !isParameterRequest)
        {
            // This class only provides completions on attribute/parameter names.
            return [];
        }
 
        var inSnippetContext = InSnippetContext(owner, context.Options);
 
        var completionContext = new DirectiveAttributeCompletionContext()
        {
            SelectedAttributeName = attributeName,
            SelectedParameterName = parameterName,
            ExistingAttributes = attributes,
            UseSnippets = inSnippetContext,
            InAttributeName = isAttributeRequest,
            InParameterName = isParameterRequest,
            Options = context.Options
        };
 
        return GetAttributeCompletions(containingTagName, completionContext, context.TagHelperDocumentContext);
 
        static bool InSnippetContext(RazorSyntaxNode owner, RazorCompletionOptions options)
        {
            return options.SnippetsSupported
                // Don't create snippet text when attribute is already in the tag and we are trying to replace it
                // Otherwise you could have something like @onabort=""=""
                && owner is not (MarkupTagHelperDirectiveAttributeSyntax or MarkupAttributeBlockSyntax)
                && owner.Parent is not (MarkupTagHelperDirectiveAttributeSyntax or MarkupAttributeBlockSyntax);
        }
    }
 
    // Internal for testing
    internal static ImmutableArray<RazorCompletionItem> GetAttributeCompletions(
        string containingTagName,
        DirectiveAttributeCompletionContext completionContext,
        TagHelperDocumentContext documentContext)
    {
        var tagHelpersForTag = TagHelperFacts.GetTagHelpersGivenTag(documentContext, containingTagName, parentTag: null);
        if (tagHelpersForTag.IsEmpty)
        {
            // If the current tag has no possible tag helpers then we can't have any directive attributes.
            return [];
        }
 
        // Use ordinal dictionary because attributes are case sensitive when matching
        using var _ = SpecializedPools.GetPooledStringDictionary<AttributeCompletionDetails>(out var attributeCompletions);
 
        // Collect indexer bound attributes and their parent tag helper type names. Indexer attributes indicate an attribute prefix
        // for which they apply. That can be used in an attribute name context to determine potential parameters. E.g.,
        // there exists an indexer indicating it applies to attributes that start with "@bind-" and specifies six different
        // parameters applicable for those attributes (":format", ":event", ":culture", ":get", ":set", ":after")
        var indexerAttributes = new MemoryBuilder<BoundAttributeDescriptor>(initialCapacity: 8, clearArray: true);
        try
        {
            CollectIndexerDescriptors(tagHelpersForTag, completionContext, ref indexerAttributes);
 
            foreach (var tagHelper in tagHelpersForTag)
            {
                foreach (var attribute in tagHelper.BoundAttributes)
                {
                    if (attribute.IsDirectiveAttribute)
                    {
                        AddAttributeNameCompletions(attribute, completionContext, attributeCompletions);
                        AddParameterNameCompletions(attribute, indexerAttributes.AsMemory().Span, completionContext, attributeCompletions);
                    }
                }
            }
 
            // Use the mapping populated above to create completion items
            return CreateCompletionItems(completionContext, attributeCompletions);
        }
        finally
        {
            indexerAttributes.Dispose();
        }
    }
 
    private static void CollectIndexerDescriptors(
        TagHelperCollection tagHelpersForTag,
        DirectiveAttributeCompletionContext completionContext,
        ref MemoryBuilder<BoundAttributeDescriptor> builder)
    {
        if (completionContext.InParameterName)
        {
            // No need to calculate the indexers When in a parameter name
            return;
        }
 
        foreach (var tagHelper in tagHelpersForTag)
        {
            foreach (var attribute in tagHelper.BoundAttributes)
            {
                if (attribute.IsDirectiveAttribute && !attribute.IndexerNamePrefix.IsNullOrEmpty())
                {
                    builder.Append(attribute);
                }
            }
        }
    }
 
    private static void AddAttributeNameCompletions(
        BoundAttributeDescriptor attribute,
        DirectiveAttributeCompletionContext completionContext,
        Dictionary<string, AttributeCompletionDetails> attributeCompletions)
    {
        if (!completionContext.InAttributeName)
        {
            // Only add attribute name completions when in an attribute name context
            return;
        }
 
        var isIndexer = completionContext.SelectedAttributeName.EndsWith(Ellipsis, StringComparison.Ordinal);
        var descriptionInfo = BoundAttributeDescriptionInfo.From(attribute, isIndexer, attribute.Parent.TypeName);
 
        var tagHelper = attribute.Parent;
 
        if (!TryAddAttributeCompletion(attribute.Name, descriptionInfo, tagHelper, completionContext, attributeCompletions) &&
            attribute.Parameters.Length > 0)
        {
            // This attribute has parameters and the base attribute name (@bind) is already satisfied. We need to check if there are any valid
            // parameters left to be provided, if so, we need to still represent the base attribute name in the completion list.
 
            foreach (var parameter in attribute.Parameters)
            {
                if (!completionContext.AlreadySatisfiesParameter(parameter, attribute))
                {
                    // This bound attribute parameter has not had a completion entry added for it, re-represent the base attribute name in the completion list
                    AddAttributeCompletion(attribute.Name, descriptionInfo, tagHelper, completionContext, attributeCompletions);
                    break;
                }
            }
        }
 
        if (!attribute.IndexerNamePrefix.IsNullOrEmpty())
        {
            TryAddAttributeCompletion(
                attribute.IndexerNamePrefix + Ellipsis, descriptionInfo, tagHelper, completionContext, attributeCompletions);
        }
    }
 
    private static void AddParameterNameCompletions(
        BoundAttributeDescriptor attribute,
        ReadOnlySpan<BoundAttributeDescriptor> indexerAttributes,
        DirectiveAttributeCompletionContext completionContext,
        Dictionary<string, AttributeCompletionDetails> attributeCompletions)
    {
        if (completionContext.InAttributeName && !attribute.IndexerNamePrefix.IsNullOrEmpty())
        {
            // Don't add parameters on indexers in attribute name contexts
            return;
        }
 
        if (completionContext.InParameterName && !completionContext.CanSatisfyAttribute(attribute))
        {
            // Don't add parameters when the selected attribute name can't satisfy the given attribute descriptor in parameter name contexts
            return;
        }
 
        // Add indexer parameter completions first so they display first in completion descriptions.
        foreach (var indexerAttribute in indexerAttributes)
        {
            var indexerNamePrefix = indexerAttribute.IndexerNamePrefix.AssumeNotNull();
 
            if (!attribute.Name.StartsWith(indexerNamePrefix))
            {
                continue;
            }
 
            AddCompletionsForParameters(attribute, indexerAttribute.Parameters, completionContext, attributeCompletions);
        }
 
        // Then add regular parameter completions
        AddCompletionsForParameters(attribute, attribute.Parameters, completionContext, attributeCompletions);
 
        return;
 
        static void AddCompletionsForParameters(
            BoundAttributeDescriptor attribute,
            ImmutableArray<BoundAttributeParameterDescriptor> parameters,
            DirectiveAttributeCompletionContext completionContext,
            Dictionary<string, AttributeCompletionDetails> attributeCompletions)
        {
            var tagHelper = attribute.Parent;
 
            foreach (var parameter in parameters)
            {
                if (completionContext.AlreadySatisfiesParameter(parameter, attribute))
                {
                    // There's already an existing attribute that satisfies this parameter, don't show it in the completion list.
                    continue;
                }
 
                var displayName = completionContext.InParameterName
                    ? parameter.Name
                    : $"{attribute.Name}:{parameter.Name}";
 
                AddParameterCompletion(
                    displayName,
                    descriptionInfo: BoundAttributeDescriptionInfo.From(parameter),
                    tagHelper,
                    completionContext,
                    attributeCompletions);
            }
        }
    }
 
    private static ImmutableArray<RazorCompletionItem> CreateCompletionItems(
        DirectiveAttributeCompletionContext completionContext,
        Dictionary<string, AttributeCompletionDetails> attributeCompletions)
    {
        using var completionItems = new PooledArrayBuilder<RazorCompletionItem>(capacity: attributeCompletions.Count);
 
        foreach (var (displayText, (kind, descriptions, commitCharacters)) in attributeCompletions)
        {
            var isIndexer = displayText.EndsWith(Ellipsis, StringComparison.Ordinal);
            var isSnippet = !isIndexer && completionContext.UseSnippets;
            var autoInsertAttributeQuotes = completionContext.Options.AutoInsertAttributeQuotes;
 
            var insertText = ComputeInsertText(displayText, isIndexer, isSnippet, autoInsertAttributeQuotes);
 
            Debug.Assert(kind is RazorCompletionItemKind.DirectiveAttribute or RazorCompletionItemKind.DirectiveAttributeParameter);
 
            var razorCompletionItem = kind == RazorCompletionItemKind.DirectiveAttribute
                ? RazorCompletionItem.CreateDirectiveAttribute(displayText, insertText, descriptionInfo: new(descriptions), commitCharacters, isSnippet)
                : RazorCompletionItem.CreateDirectiveAttributeParameter(displayText, insertText, descriptionInfo: new(descriptions), commitCharacters, isSnippet);
 
            completionItems.Add(razorCompletionItem);
        }
 
        return completionItems.ToImmutableAndClear();
    }
 
    private static string ComputeInsertText(string displayText, bool isIndexer, bool isSnippet, bool autoInsertAttributeQuotes)
    {
        var originalInsertText = displayText.AsMemory();
 
        // Strip off the @ from the insertion text. This change is here to align the insertion text with the
        // completion hooks into VS and VSCode. Basically, completion triggers when `@` is typed so we don't
        // want to insert `@bind` because `@` already exists.
        var insertText = originalInsertText.Span.StartsWith('@')
            ? originalInsertText[1..]
            : originalInsertText;
 
        // Indexer attribute, we don't want to insert with the triple dot.
        if (isIndexer)
        {
            Debug.Assert(insertText.Span.EndsWith(Ellipsis, StringComparison.Ordinal));
            return insertText[..^3].ToString();
        }
 
        if (isSnippet)
        {
            var suffixText = autoInsertAttributeQuotes
                ? QuotedAttributeValueSnippetSuffix
                : UnquotedAttributeValueSnippetSuffix;
 
            // We are trying for snippet text only for non-indexer attributes, e.g. *not* something like "@bind-..."
            return string.Create(
                length: insertText.Length + suffixText.Length,
                state: (insertText, suffixText),
                static (destination, state) =>
                {
                    var (insertText, suffixText) = state;
 
                    insertText.Span.CopyTo(destination);
                    suffixText.AsSpan().CopyTo(destination[insertText.Length..]);
                });
        }
 
        // Don't create another string unnecessarily, even though ReadOnlySpan.ToString() special-cases
        // the string to avoid allocation.
        return insertText.Span == originalInsertText.Span
            ? displayText
            : insertText.ToString();
    }
 
    private static bool TryAddAttributeCompletion(
        string attributeName,
        BoundAttributeDescriptionInfo descriptionInfo,
        TagHelperDescriptor tagHelper,
        DirectiveAttributeCompletionContext completionContext,
        Dictionary<string, AttributeCompletionDetails> attributeCompletions)
    {
        if (completionContext.SelectedAttributeName != attributeName &&
            completionContext.ExistingAttributes.Contains(attributeName))
        {
            // Attribute is already present on this element and it is not the selected attribute.
            // It shouldn't exist in the completion list.
            return false;
        }
 
        AddAttributeCompletion(attributeName, descriptionInfo, tagHelper, completionContext, attributeCompletions);
        return true;
    }
 
    private static void AddAttributeCompletion(
        string attributeName,
        BoundAttributeDescriptionInfo descriptionInfo,
        TagHelperDescriptor tagHelper,
        DirectiveAttributeCompletionContext completionContext,
        Dictionary<string, AttributeCompletionDetails> attributeCompletions)
        => AddCompletion(RazorCompletionItemKind.DirectiveAttribute,
            attributeName, descriptionInfo, tagHelper, completionContext, attributeCompletions);
 
    private static void AddParameterCompletion(
        string attributeName,
        BoundAttributeDescriptionInfo descriptionInfo,
        TagHelperDescriptor tagHelper,
        DirectiveAttributeCompletionContext completionContext,
        Dictionary<string, AttributeCompletionDetails> attributeCompletions)
        => AddCompletion(RazorCompletionItemKind.DirectiveAttributeParameter,
            attributeName, descriptionInfo, tagHelper, completionContext, attributeCompletions);
 
    private static void AddCompletion(
        RazorCompletionItemKind kind,
        string attributeName,
        BoundAttributeDescriptionInfo descriptionInfo,
        TagHelperDescriptor tagHelper,
        DirectiveAttributeCompletionContext completionContext,
        Dictionary<string, AttributeCompletionDetails> attributeCompletions)
    {
        ImmutableArray<BoundAttributeDescriptionInfo> descriptions;
        ImmutableArray<RazorCommitCharacter> commitCharacters;
 
        if (attributeCompletions.TryGetValue(attributeName, out var existingDetails))
        {
            (descriptions, commitCharacters) = existingDetails;
 
            if (!descriptions.Contains(descriptionInfo))
            {
                descriptions = descriptions.Add(descriptionInfo);
            }
        }
        else
        {
            descriptions = [descriptionInfo];
            commitCharacters = [];
        }
 
        // Verify not an indexer attribute, as those don't commit with standard chars
        if (!attributeName.EndsWith(Ellipsis, StringComparison.Ordinal))
        {
            // We always add "=" as a commit character in Visual Studio.
            var useEqualsCommit = !completionContext.Options.UseVsCodeCompletionCommitCharacters ||
                                  commitCharacters.Any(static c => c.Character == "=");
 
            var useSpaceCommit = commitCharacters.Any(static c => c.Character == " ") ||
                                 tagHelper.BoundAttributes.Any(static a => a.IsBooleanProperty);
 
            commitCharacters = DefaultCommitCharacters.Get(useEqualsCommit, useSpaceCommit, completionContext.UseSnippets);
        }
 
        attributeCompletions[attributeName] = new(kind, descriptions, commitCharacters);
    }
}