File: Completion\CompletionProviders\AttributeNamedParameterCompletionProvider.cs
Web Access
Project: src\src\Features\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Features.csproj (Microsoft.CodeAnalysis.CSharp.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.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Completion.Providers;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CSharp.Completion.Providers;
 
[ExportCompletionProvider(nameof(AttributeNamedParameterCompletionProvider), LanguageNames.CSharp)]
[ExtensionOrder(After = nameof(FirstBuiltInCompletionProvider))]
[Shared]
internal class AttributeNamedParameterCompletionProvider : LSPCompletionProvider
{
    private const string EqualsString = "=";
    private const string SpaceEqualsString = " =";
    private const string ColonString = ":";
 
    private static readonly CompletionItemRules _spaceItemFilterRule = CompletionItemRules.Default.WithFilterCharacterRule(
        CharacterSetModificationRule.Create(CharacterSetModificationKind.Remove, ' '));
 
    [ImportingConstructor]
    [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
    public AttributeNamedParameterCompletionProvider()
    {
    }
 
    internal override string Language => LanguageNames.CSharp;
 
    public override bool IsInsertionTrigger(SourceText text, int characterPosition, CompletionOptions options)
        => CompletionUtilities.IsTriggerCharacter(text, characterPosition, options);
 
    public override ImmutableHashSet<char> TriggerCharacters { get; } = CompletionUtilities.CommonTriggerCharacters;
 
    public override async Task ProvideCompletionsAsync(CompletionContext context)
    {
        try
        {
            var document = context.Document;
            var position = context.Position;
            var cancellationToken = context.CancellationToken;
 
            var syntaxTree = await document.GetRequiredSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
            if (syntaxTree.IsInNonUserCode(position, cancellationToken))
            {
                return;
            }
 
            var token = syntaxTree.FindTokenOnLeftOfPosition(position, cancellationToken);
            token = token.GetPreviousTokenIfTouchingWord(position);
 
            if (token.Kind() is not (SyntaxKind.OpenParenToken or SyntaxKind.CommaToken))
            {
                return;
            }
 
            if (token.Parent!.Parent is not AttributeSyntax attributeSyntax || token.Parent is not AttributeArgumentListSyntax attributeArgumentList)
            {
                return;
            }
 
            if (IsAfterNameColonArgument(token) || IsAfterNameEqualsArgument(token))
            {
                context.IsExclusive = true;
            }
 
            // We actually want to collect two sets of named parameters to present the user.  The
            // normal named parameters that come from the attribute constructors.  These will be
            // presented like "goo:".  And also the named parameters that come from the writable
            // fields/properties in the attribute.  These will be presented like "bar =".  
 
            var existingNamedParameters = GetExistingNamedParameters(attributeArgumentList, position);
 
            var semanticModel = await document.ReuseExistingSpeculativeModelAsync(attributeSyntax, cancellationToken).ConfigureAwait(false);
            var nameColonItems = GetNameColonItems(context, semanticModel, token, attributeSyntax, existingNamedParameters);
            var nameEqualsItems = GetNameEqualsItems(context, semanticModel, token, attributeSyntax, existingNamedParameters);
 
            context.AddItems(nameEqualsItems);
 
            // If we're after a name= parameter, then we only want to show name= parameters.
            // Otherwise, show name: parameters too.
            if (!IsAfterNameEqualsArgument(token))
            {
                context.AddItems(nameColonItems);
            }
        }
        catch (Exception e) when (FatalError.ReportAndCatchUnlessCanceled(e))
        {
            // nop
        }
    }
 
    private static bool IsAfterNameColonArgument(SyntaxToken token)
    {
        if (token.Kind() == SyntaxKind.CommaToken && token.Parent is AttributeArgumentListSyntax argumentList)
        {
            foreach (var item in argumentList.Arguments.GetWithSeparators())
            {
                if (item.IsToken && item.AsToken() == token)
                {
                    return false;
                }
 
                var node = (AttributeArgumentSyntax?)item.AsNode();
                if (node?.NameColon != null)
                {
                    return true;
                }
            }
        }
 
        return false;
    }
 
    private static bool IsAfterNameEqualsArgument(SyntaxToken token)
    {
        if (token.Kind() == SyntaxKind.CommaToken && token.Parent is AttributeArgumentListSyntax argumentList)
        {
            foreach (var item in argumentList.Arguments.GetWithSeparators())
            {
                if (item.IsToken && item.AsToken() == token)
                {
                    return false;
                }
 
                var node = (AttributeArgumentSyntax?)item.AsNode();
                if (node?.NameEquals != null)
                {
                    return true;
                }
            }
        }
 
        return false;
    }
 
    private static ImmutableArray<CompletionItem> GetNameEqualsItems(
        CompletionContext context, SemanticModel semanticModel,
        SyntaxToken token, AttributeSyntax attributeSyntax, ISet<string> existingNamedParameters)
    {
        var attributeNamedParameters = GetAttributeNamedParameters(semanticModel, context.Position, attributeSyntax, context.CancellationToken);
        var unspecifiedNamedParameters = attributeNamedParameters.Where(p => !existingNamedParameters.Contains(p.Name));
 
        var rightToken = semanticModel.SyntaxTree.FindTokenOnRightOfPosition(context.Position, context.CancellationToken);
        var displayTextSuffix = rightToken.IsKind(SyntaxKind.EqualsToken) ? null : SpaceEqualsString;
 
        var q = from p in attributeNamedParameters
                where !existingNamedParameters.Contains(p.Name)
                select SymbolCompletionItem.CreateWithSymbolId(
                   displayText: p.Name.ToIdentifierToken().ToString(),
                   displayTextSuffix: displayTextSuffix,
                   insertionText: null,
                   symbols: ImmutableArray.Create(p),
                   contextPosition: token.SpanStart,
                   sortText: p.Name,
                   rules: _spaceItemFilterRule);
        return q.ToImmutableArray();
    }
 
    private static IEnumerable<CompletionItem> GetNameColonItems(
        CompletionContext context, SemanticModel semanticModel, SyntaxToken token, AttributeSyntax attributeSyntax, ISet<string> existingNamedParameters)
    {
        var parameterLists = GetParameterLists(semanticModel, context.Position, attributeSyntax, context.CancellationToken);
        parameterLists = parameterLists.Where(pl => IsValid(pl, existingNamedParameters));
 
        var rightToken = semanticModel.SyntaxTree.FindTokenOnRightOfPosition(context.Position, context.CancellationToken);
        var displayTextSuffix = rightToken.IsKind(SyntaxKind.ColonToken) ? null : ColonString;
 
        return from pl in parameterLists
               from p in pl
               where !existingNamedParameters.Contains(p.Name)
               select SymbolCompletionItem.CreateWithSymbolId(
                   displayText: p.Name.ToIdentifierToken().ToString(),
                   displayTextSuffix: displayTextSuffix,
                   insertionText: null,
                   symbols: [p],
                   contextPosition: token.SpanStart,
                   sortText: p.Name,
                   rules: CompletionItemRules.Default);
    }
 
    internal override Task<CompletionDescription> GetDescriptionWorkerAsync(Document document, CompletionItem item, CompletionOptions options, SymbolDescriptionOptions displayOptions, CancellationToken cancellationToken)
        => SymbolCompletionItem.GetDescriptionAsync(item, document, displayOptions, cancellationToken);
 
    private static bool IsValid(ImmutableArray<IParameterSymbol> parameterList, ISet<string> existingNamedParameters)
        => existingNamedParameters.Except(parameterList.Select(p => p.Name)).IsEmpty();
 
    private static ISet<string> GetExistingNamedParameters(AttributeArgumentListSyntax argumentList, int position)
    {
        var existingArguments1 =
            argumentList.Arguments.Where(a => a.Span.End <= position)
                                  .Where(a => a.NameColon != null)
                                  .Select(a => a.NameColon!.Name.Identifier.ValueText);
        var existingArguments2 =
            argumentList.Arguments.Where(a => a.Span.End <= position)
                                  .Where(a => a.NameEquals != null)
                                  .Select(a => a.NameEquals!.Name.Identifier.ValueText);
 
        return existingArguments1.Concat(existingArguments2).ToSet();
    }
 
    private static IEnumerable<ImmutableArray<IParameterSymbol>> GetParameterLists(
        SemanticModel semanticModel,
        int position,
        AttributeSyntax attribute,
        CancellationToken cancellationToken)
    {
        var within = semanticModel.GetEnclosingNamedTypeOrAssembly(position, cancellationToken);
        if (within != null && semanticModel.GetTypeInfo(attribute, cancellationToken).Type is INamedTypeSymbol attributeType)
        {
            return attributeType.InstanceConstructors.Where(c => c.IsAccessibleWithin(within))
                                                     .Select(c => c.Parameters);
        }
 
        return [];
    }
 
    private static IEnumerable<ISymbol> GetAttributeNamedParameters(
        SemanticModel semanticModel,
        int position,
        AttributeSyntax attribute,
        CancellationToken cancellationToken)
    {
        var within = semanticModel.GetEnclosingNamedTypeOrAssembly(position, cancellationToken);
        var attributeType = (INamedTypeSymbol?)semanticModel.GetTypeInfo(attribute, cancellationToken).Type;
        Contract.ThrowIfNull(attributeType);
        return attributeType.GetAttributeNamedParameters(semanticModel.Compilation, within);
    }
 
    protected override Task<TextChange?> GetTextChangeAsync(CompletionItem selectedItem, char? ch, CancellationToken cancellationToken)
        => Task.FromResult(GetTextChange(selectedItem, ch));
 
    private static TextChange? GetTextChange(CompletionItem selectedItem, char? ch)
    {
        var displayText = selectedItem.DisplayText + selectedItem.DisplayTextSuffix;
 
        if (ch != null)
        {
            // If user types a space, do not complete the " =" (space and equals) at the end of a named parameter. The
            // typed space character will be passed through to the editor, and they can then type the '='.
            if (ch == ' ' && displayText.EndsWith(SpaceEqualsString, StringComparison.Ordinal))
            {
                return new TextChange(selectedItem.Span, displayText.Remove(displayText.Length - SpaceEqualsString.Length));
            }
 
            // If the user types '=', do not complete the '=' at the end of the named parameter because the typed '=' 
            // will be passed through to the editor.
            if (ch == '=' && displayText.EndsWith(EqualsString, StringComparison.Ordinal))
            {
                return new TextChange(selectedItem.Span, displayText.Remove(displayText.Length - EqualsString.Length));
            }
 
            // If the user types ':', do not complete the ':' at the end of the named parameter because the typed ':' 
            // will be passed through to the editor.
            if (ch == ':' && displayText.EndsWith(ColonString, StringComparison.Ordinal))
            {
                return new TextChange(selectedItem.Span, displayText.Remove(displayText.Length - ColonString.Length));
            }
        }
 
        return new TextChange(selectedItem.Span, displayText);
    }
}