Просмотр исходного кода

Route tooling: Add framework route parameter completion (#42910)

Co-authored-by: Brennan <[email protected]>
James Newton-King 3 лет назад
Родитель
Сommit
fc7c8d79f1

+ 630 - 0
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/FrameworkParametersCompletionProvider.cs

@@ -0,0 +1,630 @@
+// 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.Composition;
+using System.Diagnostics;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
+using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure.VirtualChars;
+using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.RoutePattern;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Completion;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Options;
+using Microsoft.CodeAnalysis.Tags;
+using Microsoft.CodeAnalysis.Text;
+using Document = Microsoft.CodeAnalysis.Document;
+using RoutePatternToken = Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure.EmbeddedSyntax.EmbeddedSyntaxToken<Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.RoutePattern.RoutePatternKind>;
+
+namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;
+
+[ExportCompletionProvider(nameof(RoutePatternCompletionProvider), LanguageNames.CSharp)]
+[Shared]
+public sealed class FrameworkParametersCompletionProvider : CompletionProvider
+{
+    private const string StartKey = nameof(StartKey);
+    private const string LengthKey = nameof(LengthKey);
+    private const string NewTextKey = nameof(NewTextKey);
+    private const string NewPositionKey = nameof(NewPositionKey);
+    private const string DescriptionKey = nameof(DescriptionKey);
+
+    // Always soft-select these completion items.  Also, never filter down.
+    private static readonly CompletionItemRules s_rules = CompletionItemRules.Create(
+        selectionBehavior: CompletionItemSelectionBehavior.SoftSelection,
+        filterCharacterRules: ImmutableArray.Create(CharacterSetModificationRule.Create(CharacterSetModificationKind.Replace, Array.Empty<char>())));
+
+    // The space between type and parameter name.
+    // void TestMethod(int // <- space after type name
+    public ImmutableHashSet<char> TriggerCharacters { get; } = ImmutableHashSet.Create(' ');
+
+    public override bool ShouldTriggerCompletion(SourceText text, int caretPosition, CompletionTrigger trigger, OptionSet options)
+    {
+        if (trigger.Kind is CompletionTriggerKind.Invoke or CompletionTriggerKind.InvokeAndCommitIfUnique)
+        {
+            return true;
+        }
+
+        if (trigger.Kind == CompletionTriggerKind.Insertion)
+        {
+            return TriggerCharacters.Contains(trigger.Character);
+        }
+
+        return false;
+    }
+
+    public override Task<CompletionDescription> GetDescriptionAsync(Document document, CompletionItem item, CancellationToken cancellationToken)
+    {
+        if (!item.Properties.TryGetValue(DescriptionKey, out var description))
+        {
+            return Task.FromResult<CompletionDescription>(null);
+        }
+
+        return Task.FromResult(CompletionDescription.Create(
+            ImmutableArray.Create(new TaggedText(TextTags.Text, description))));
+    }
+
+    public override Task<CompletionChange> GetChangeAsync(Document document, CompletionItem item, char? commitKey, CancellationToken cancellationToken)
+    {
+        // These values have always been added by us.
+        var startString = item.Properties[StartKey];
+        var lengthString = item.Properties[LengthKey];
+        var newText = item.Properties[NewTextKey];
+
+        // This value is optionally added in some cases and may not always be there.
+        item.Properties.TryGetValue(NewPositionKey, out var newPositionString);
+
+        return Task.FromResult(CompletionChange.Create(
+            new TextChange(new TextSpan(int.Parse(startString, CultureInfo.InvariantCulture), int.Parse(lengthString, CultureInfo.InvariantCulture)), newText),
+            newPositionString == null ? null : int.Parse(newPositionString, CultureInfo.InvariantCulture)));
+    }
+
+    public override async Task ProvideCompletionsAsync(CompletionContext context)
+    {
+        var position = context.Position;
+
+        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+        if (root == null)
+        {
+            return;
+        }
+
+        SyntaxToken? parentOpt = null;
+
+        var token = root.FindTokenOnLeftOfPosition(position);
+
+        // If space is after ? or > then it's likely a nullable or generic type. Move to previous type token.
+        if (token.IsKind(SyntaxKind.QuestionToken) || token.IsKind(SyntaxKind.GreaterThanToken))
+        {
+            token = token.GetPreviousToken();
+        }
+
+        // Whitespace should follow the identifier token of the parameter.
+        if (!IsArgumentTypeToken(token))
+        {
+            return;
+        }
+
+        var container = TryFindMinimalApiArgument(token.Parent) ?? TryFindMvcActionParameter(token.Parent);
+        if (container == null)
+        {
+            return;
+        }
+
+        var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+        if (semanticModel == null)
+        {
+            return;
+        }
+
+        if (!WellKnownTypes.TryGetOrCreate(semanticModel.Compilation, out var wellKnownTypes))
+        {
+            return;
+        }
+
+        // Don't offer route parameter names when the parameter type can't be bound to route parameters.
+        // e.g. special types like HttpContext, non-primitive types that don't have a static TryParse method.
+        if (!IsCurrentParameterBindable(token, semanticModel, wellKnownTypes, context.CancellationToken))
+        {
+            return;
+        }
+
+        // Don't offer route parameter names when the parameter has an attribute that can't be bound to route parameters.
+        // e.g [AsParameters] or [IFromBodyMetadata].
+        var hasNonRouteAttribute = HasNonRouteAttribute(token, semanticModel, wellKnownTypes, context.CancellationToken);
+        if (hasNonRouteAttribute)
+        {
+            return;
+        }
+
+        SyntaxToken routeStringToken;
+        SyntaxNode methodNode;
+
+        if (container.Parent.IsKind(SyntaxKind.Argument))
+        {
+            // Minimal API
+            var mapMethodParts = RoutePatternUsageDetector.FindMapMethodParts(semanticModel, wellKnownTypes, container, context.CancellationToken);
+            if (mapMethodParts == null)
+            {
+                return;
+            }
+            var (_, routeStringExpression, delegateExpression) = mapMethodParts.Value;
+
+            routeStringToken = routeStringExpression.Token;
+            methodNode = delegateExpression;
+
+            // Incomplete inline delegate syntax is very messy and arguments are mixed together.
+            // Examine tokens to figure out wether the current token is the argument name.
+            var previous = token.GetPreviousToken();
+            if (previous.IsKind(SyntaxKind.CommaToken) ||
+                previous.IsKind(SyntaxKind.OpenParenToken) ||
+                previous.IsKind(SyntaxKind.OutKeyword) ||
+                previous.IsKind(SyntaxKind.InKeyword) ||
+                previous.IsKind(SyntaxKind.RefKeyword) ||
+                previous.IsKind(SyntaxKind.ParamsKeyword) ||
+                (previous.IsKind(SyntaxKind.CloseBracketToken) && previous.GetRequiredParent().FirstAncestorOrSelf<AttributeListSyntax>() != null) ||
+                (previous.IsKind(SyntaxKind.LessThanToken) && previous.GetRequiredParent().FirstAncestorOrSelf<GenericNameSyntax>() != null))
+            {
+                // Positioned after type token. Don't replace current.
+            }
+            else
+            {
+                // Space after argument name. Don't show completion options.
+                if (context.Trigger.Kind == CompletionTriggerKind.Insertion)
+                {
+                    return;
+                }
+
+                parentOpt = token;
+            }
+        }
+        else if (container.IsKind(SyntaxKind.Parameter))
+        {
+            // MVC
+            var methodSyntax = container.FirstAncestorOrSelf<MethodDeclarationSyntax>();
+            var methodSymbol = semanticModel.GetDeclaredSymbol(methodSyntax, context.CancellationToken);
+
+            // Check method is a valid MVC action.
+            if (methodSymbol?.ContainingType is not INamedTypeSymbol typeSymbol ||
+                !MvcDetector.IsController(typeSymbol, wellKnownTypes) ||
+                !MvcDetector.IsAction(methodSymbol, wellKnownTypes))
+            {
+                return;
+            }
+
+            var routeToken = TryGetMvcActionRouteToken(context, semanticModel, methodSyntax);
+            if (routeToken == null)
+            {
+                return;
+            }
+
+            routeStringToken = routeToken.Value;
+            methodNode = methodSyntax;
+
+            if (((ParameterSyntax)container).Identifier == token)
+            {
+                // Space after argument name. Don't show completion options.
+                if (context.Trigger.Kind == CompletionTriggerKind.Insertion)
+                {
+                    return;
+                }
+
+                parentOpt = token;
+            }
+        }
+        else
+        {
+            return;
+        }
+
+        var virtualChars = CSharpVirtualCharService.Instance.TryConvertToVirtualChars(routeStringToken);
+        var tree = RoutePatternParser.TryParse(virtualChars, supportTokenReplacement: false);
+        if (tree == null)
+        {
+            return;
+        }
+
+        var routePatternCompletionContext = new EmbeddedCompletionContext(context, tree, wellKnownTypes);
+
+        var existingParameterNames = GetExistingParameterNames(methodNode);
+        foreach (var parameterName in existingParameterNames)
+        {
+            routePatternCompletionContext.AddUsedParameterName(parameterName);
+        }
+
+        ProvideCompletions(routePatternCompletionContext, parentOpt);
+
+        if (routePatternCompletionContext.Items == null || routePatternCompletionContext.Items.Count == 0)
+        {
+            return;
+        }
+
+        foreach (var embeddedItem in routePatternCompletionContext.Items)
+        {
+            var change = embeddedItem.Change;
+            var textChange = change.TextChange;
+
+            var properties = ImmutableDictionary.CreateBuilder<string, string>();
+            properties.Add(StartKey, textChange.Span.Start.ToString(CultureInfo.InvariantCulture));
+            properties.Add(LengthKey, textChange.Span.Length.ToString(CultureInfo.InvariantCulture));
+            properties.Add(NewTextKey, textChange.NewText);
+            properties.Add(DescriptionKey, embeddedItem.FullDescription);
+
+            if (change.NewPosition != null)
+            {
+                properties.Add(NewPositionKey, change.NewPosition.ToString());
+            }
+
+            // Keep everything sorted in the order we just produced the items in.
+            var sortText = routePatternCompletionContext.Items.Count.ToString("0000", CultureInfo.InvariantCulture);
+            context.AddItem(CompletionItem.Create(
+                displayText: embeddedItem.DisplayText,
+                inlineDescription: "",
+                sortText: sortText,
+                properties: properties.ToImmutable(),
+                rules: s_rules,
+                tags: ImmutableArray.Create(embeddedItem.Glyph)));
+        }
+
+        context.SuggestionModeItem = CompletionItem.Create(
+            displayText: "<Name>",
+            inlineDescription: "",
+            rules: CompletionItemRules.Default);
+
+        context.IsExclusive = true;
+    }
+
+    private static bool IsArgumentTypeToken(SyntaxToken token)
+    {
+        return SyntaxFacts.IsPredefinedType(token.Kind()) || token.IsKind(SyntaxKind.IdentifierToken);
+    }
+
+    private static SyntaxToken? TryGetMvcActionRouteToken(CompletionContext context, SemanticModel? semanticModel, MethodDeclarationSyntax? method)
+    {
+        foreach (var attributeList in method.AttributeLists)
+        {
+            foreach (var attribute in attributeList.Attributes)
+            {
+                foreach (var attributeArgument in attribute.ArgumentList.Arguments)
+                {
+                    if (RouteStringSyntaxDetector.IsArgumentToAttributeParameterWithMatchingStringSyntaxAttribute(
+                        semanticModel,
+                        attributeArgument,
+                        context.CancellationToken,
+                        out var identifer) &&
+                        identifer == "Route" &&
+                        attributeArgument.Expression is LiteralExpressionSyntax literalExpression)
+                    {
+                        return literalExpression.Token;
+                    }
+                }
+            }
+        }
+
+        return null;
+    }
+
+    private static SyntaxNode? TryFindMvcActionParameter(SyntaxNode node)
+    {
+        var current = node;
+        while (current != null)
+        {
+            if (current.IsKind(SyntaxKind.Parameter))
+            {
+                return current;
+            }
+
+            current = current.Parent;
+        }
+
+        return null;
+    }
+
+    private static SyntaxNode? TryFindMinimalApiArgument(SyntaxNode node)
+    {
+        var current = node;
+        while (current != null)
+        {
+            if (current.Parent?.IsKind(SyntaxKind.Argument) ?? false)
+            {
+                if (current.Parent?.Parent?.IsKind(SyntaxKind.ArgumentList) ?? false)
+                {
+                    return current;
+                }
+            }
+
+            current = current.Parent;
+        }
+
+        return null;
+    }
+
+    private static bool HasNonRouteAttribute(SyntaxToken token, SemanticModel semanticModel, WellKnownTypes wellKnownTypes, CancellationToken cancellationToken)
+    {
+        if (token.Parent?.Parent is ParameterSyntax parameter)
+        {
+            foreach (var attributeList in parameter.AttributeLists.OfType<AttributeListSyntax>())
+            {
+                foreach (var attribute in attributeList.Attributes)
+                {
+                    var attributeTypeSymbol = semanticModel.GetSymbolInfo(attribute, cancellationToken).GetAnySymbol();
+
+                    if (attributeTypeSymbol != null)
+                    {
+                        foreach (var nonRouteMetadataType in wellKnownTypes.NonRouteMetadataTypes)
+                        {
+                            if (attributeTypeSymbol.ContainingSymbol is ITypeSymbol typeSymbol &&
+                                typeSymbol.Implements(nonRouteMetadataType))
+                            {
+                                return true;
+                            }
+                        }
+                        if (SymbolEqualityComparer.Default.Equals(attributeTypeSymbol.ContainingSymbol, wellKnownTypes.AsParametersAttribute))
+                        {
+                            return true;
+                        }
+                    }
+                }
+            }
+        }
+
+        return false;
+    }
+
+    private static bool IsCurrentParameterBindable(SyntaxToken token, SemanticModel semanticModel, WellKnownTypes wellKnownTypes, CancellationToken cancellationToken)
+    {
+        if (token.Parent.IsKind(SyntaxKind.PredefinedType))
+        {
+            return true;
+        }
+
+        var parameterTypeSymbol = semanticModel.GetSymbolInfo(token.Parent, cancellationToken).GetAnySymbol();
+        if (parameterTypeSymbol is INamedTypeSymbol typeSymbol)
+        {
+            // String is valid.
+            if (typeSymbol.SpecialType == SpecialType.System_String)
+            {
+                return true;
+            }
+            // Any enum is valid.
+            if (typeSymbol.TypeKind == TypeKind.Enum)
+            {
+                return true;
+            }
+            // Uri is valid.
+            if (SymbolEqualityComparer.Default.Equals(typeSymbol, wellKnownTypes.Uri))
+            {
+                return true;
+            }
+
+            // Check if the parameter type has a public static TryParse method.
+            foreach (var item in typeSymbol.GetMembers("TryParse"))
+            {
+                // bool TryParse(string input, out T value)
+                if (IsTryParse(item))
+                {
+                    return true;
+                }
+
+                // bool TryParse(string input, IFormatProvider provider, out T value)
+                if (IsTryParseWithFormat(item, wellKnownTypes))
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+        else if (parameterTypeSymbol is IMethodSymbol)
+        {
+            // If the parameter type is a method then the method is bound to the minimal API.
+            return false;
+        }
+
+        // The cursor is on an identifier (parameter name) and completion is explicitly triggered (e.g. CTRL+SPACE)
+        return true;
+        
+        static bool IsTryParse(ISymbol item)
+        {
+            return item is IMethodSymbol methodSymbol &&
+                methodSymbol.DeclaredAccessibility == Accessibility.Public &&
+                methodSymbol.IsStatic &&
+                methodSymbol.ReturnType.SpecialType == SpecialType.System_Boolean &&
+                methodSymbol.Parameters.Length == 2 &&
+                methodSymbol.Parameters[0].Type.SpecialType == SpecialType.System_String &&
+                methodSymbol.Parameters[1].RefKind == RefKind.Out;
+        }
+
+        static bool IsTryParseWithFormat(ISymbol item, WellKnownTypes wellKnownTypes)
+        {
+            return item is IMethodSymbol methodSymbol &&
+                methodSymbol.DeclaredAccessibility == Accessibility.Public &&
+                methodSymbol.IsStatic &&
+                methodSymbol.ReturnType.SpecialType == SpecialType.System_Boolean &&
+                methodSymbol.Parameters.Length == 3 &&
+                methodSymbol.Parameters[0].Type.SpecialType == SpecialType.System_String &&
+                SymbolEqualityComparer.Default.Equals(methodSymbol.Parameters[1].Type, wellKnownTypes.IFormatProvider) &&
+                methodSymbol.Parameters[2].RefKind == RefKind.Out;
+        }
+    }
+
+    private static ImmutableArray<string> GetExistingParameterNames(SyntaxNode node)
+    {
+        var builder = ImmutableArray.CreateBuilder<string>();
+
+        if (node is TupleExpressionSyntax tupleExpression)
+        {
+            foreach (var argument in tupleExpression.Arguments)
+            {
+                if (argument.Expression is DeclarationExpressionSyntax declarationExpression &&
+                    declarationExpression.Designation is SingleVariableDesignationSyntax variableDesignationSyntax &&
+                    variableDesignationSyntax.Identifier is { IsMissing: false } identifer)
+                {
+                    builder.Add(identifer.ValueText);
+                }
+            }
+        }
+        else
+        {
+            var parameterList = node switch
+            {
+                ParenthesizedLambdaExpressionSyntax parenthesizedLambdaExpression => parenthesizedLambdaExpression.ParameterList,
+                MethodDeclarationSyntax methodDeclaration => methodDeclaration.ParameterList,
+                _ => null
+            };
+
+            if (parameterList != null)
+            {
+                foreach (var p in parameterList.Parameters)
+                {
+                    if (p is ParameterSyntax parameter &&
+                        parameter.Identifier is { IsMissing: false } identifer)
+                    {
+                        builder.Add(identifer.ValueText);
+                    }
+                }
+            }
+        }
+
+        return builder.ToImmutable();
+    }
+
+    private static void ProvideCompletions(EmbeddedCompletionContext context, SyntaxToken? parentOpt)
+    {
+        foreach (var routeParameter in context.Tree.RouteParameters)
+        {
+            context.AddIfMissing(routeParameter.Name, suffix: null, description: "(Route parameter)", WellKnownTags.Parameter, parentOpt);
+        }
+    }
+
+    private (RoutePatternNode parent, RoutePatternToken Token)? FindToken(RoutePatternNode parent, VirtualChar ch)
+    {
+        foreach (var child in parent)
+        {
+            if (child.IsNode)
+            {
+                var result = FindToken(child.Node, ch);
+                if (result != null)
+                {
+                    return result;
+                }
+            }
+            else
+            {
+                if (child.Token.VirtualChars.Contains(ch))
+                {
+                    return (parent, child.Token);
+                }
+            }
+        }
+
+        return null;
+    }
+
+    private readonly struct RoutePatternItem
+    {
+        public readonly string DisplayText;
+        public readonly string InlineDescription;
+        public readonly string FullDescription;
+        public readonly string Glyph;
+        public readonly CompletionChange Change;
+
+        public RoutePatternItem(
+            string displayText, string inlineDescription, string fullDescription, string glyph, CompletionChange change)
+        {
+            DisplayText = displayText;
+            InlineDescription = inlineDescription;
+            FullDescription = fullDescription;
+            Glyph = glyph;
+            Change = change;
+        }
+    }
+
+    private readonly struct EmbeddedCompletionContext
+    {
+        private readonly CompletionContext _context;
+        private readonly HashSet<string> _names = new(StringComparer.OrdinalIgnoreCase);
+
+        public readonly RoutePatternTree Tree;
+        public readonly WellKnownTypes WellKnownTypes;
+        public readonly CancellationToken CancellationToken;
+        public readonly int Position;
+        public readonly CompletionTrigger Trigger;
+        public readonly List<RoutePatternItem> Items = new();
+        public readonly CompletionListSpanContainer CompletionListSpan = new();
+
+        internal class CompletionListSpanContainer
+        {
+            public TextSpan? Value { get; set; }
+        }
+
+        public EmbeddedCompletionContext(
+            CompletionContext context,
+            RoutePatternTree tree,
+            WellKnownTypes wellKnownTypes)
+        {
+            _context = context;
+            Tree = tree;
+            WellKnownTypes = wellKnownTypes;
+            Position = _context.Position;
+            Trigger = _context.Trigger;
+            CancellationToken = _context.CancellationToken;
+        }
+
+        public void AddUsedParameterName(string name)
+        {
+            _names.Add(name);
+        }
+
+        public void AddIfMissing(
+            string displayText, string suffix, string description, string glyph,
+            SyntaxToken? parentOpt, int? positionOffset = null, string insertionText = null)
+        {
+            var replacementStart = parentOpt != null
+                ? parentOpt.Value.GetLocation().SourceSpan.Start
+                : Position;
+            var replacementEnd = parentOpt != null
+                ? parentOpt.Value.GetLocation().SourceSpan.End
+                : Position;
+
+            var replacementSpan = TextSpan.FromBounds(replacementStart, replacementEnd);
+            var newPosition = replacementStart + positionOffset;
+
+            insertionText ??= displayText;
+
+            AddIfMissing(new RoutePatternItem(
+                displayText, suffix, description, glyph,
+                CompletionChange.Create(
+                    new TextChange(replacementSpan, insertionText),
+                    newPosition)));
+        }
+
+        public void AddIfMissing(RoutePatternItem item)
+        {
+            if (_names.Add(item.DisplayText))
+            {
+                Items.Add(item);
+            }
+        }
+
+        public static string EscapeText(string text, SyntaxToken token)
+        {
+            // This function is called when Completion needs to escape something its going to
+            // insert into the user's string token.  This means that we only have to escape
+            // things that completion could insert.  In this case, the only regex character
+            // that is relevant is the \ character, and it's only relevant if we insert into
+            // a normal string and not a verbatim string.  There are no other regex characters
+            // that completion will produce that need any escaping. 
+            Debug.Assert(token.IsKind(SyntaxKind.StringLiteralToken));
+            return token.IsVerbatimStringLiteral()
+                ? text
+                : text.Replace(@"\", @"\\");
+        }
+    }
+}

+ 16 - 0
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/WellKnownTypes.cs

@@ -144,6 +144,18 @@ internal sealed class WellKnownTypes
             return false;
         }
 
+        const string IFormatProvider = "System.IFormatProvider";
+        if (compilation.GetTypeByMetadataName(IFormatProvider) is not { } iFormatProvider)
+        {
+            return false;
+        }
+
+        const string Uri = "System.Uri";
+        if (compilation.GetTypeByMetadataName(Uri) is not { } uri)
+        {
+            return false;
+        }
+
         wellKnownTypes = new()
         {
             IFromBodyMetadata = iFromBodyMetadata,
@@ -165,6 +177,8 @@ internal sealed class WellKnownTypes
             IFormFile = iFormFile,
             Stream = stream,
             PipeReader = pipeReader,
+            IFormatProvider = iFormatProvider,
+            Uri = uri,
         };
 
         return true;
@@ -189,6 +203,8 @@ internal sealed class WellKnownTypes
     public INamedTypeSymbol Stream { get; private init; }
     public INamedTypeSymbol PipeReader { get; private init; }
     public INamedTypeSymbol IFormFile { get; private init; }
+    public INamedTypeSymbol IFormatProvider { get; private init; }
+    public INamedTypeSymbol Uri { get; private init; }
 
     private INamedTypeSymbol[]? _parameterSpecialTypes;
     public INamedTypeSymbol[] ParameterSpecialTypes

+ 1097 - 0
src/Framework/AspNetCoreAnalyzers/test/RouteEmbeddedLanguage/FrameworkParametersCompletionProviderTests.cs

@@ -0,0 +1,1097 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
+using Microsoft.CodeAnalysis.Completion;
+
+namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;
+
+public partial class FrameworkParametersCompletionProviderTests
+{
+    private TestDiagnosticAnalyzerRunner Runner { get; } = new(new RoutePatternAnalyzer());
+
+    [Fact]
+    public async Task Insertion_Space_Int_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (int $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_DateTime_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (DateTime $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_NullableInt_CloseParen_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (int? $$)
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_NullableInt_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (int? $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_OutInt_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        // Out parameters are not supported by Minimal API.
+        // It's useful to provide completion here and then allow dev to fix parameter later.
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (out int $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_Generic_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (Nullable<int> $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Invoke_Space_Generic_EndpointMapGet_HasDelegate_HasText_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (int [|i|]$$
+    }
+}
+", CompletionTrigger.Invoke);
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Invoke_Space_Generic_EndpointMapGet_HasDelegate_InText_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (int [|i$$d|]
+    }
+}
+", CompletionTrigger.Invoke);
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Invoke_Space_Generic_EndpointMapGet_HasCompleteDelegate_InText_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{ids}"", (int [|i$$d|]) => {});
+    }
+}
+", CompletionTrigger.Invoke);
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("ids", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("ids", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Insertion_FirstArgument_SpaceAfterIdentifer_EndpointMapGet_HasDelegate_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (int i $$
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_SecondArgument_SpaceAfterIdentifer_EndpointMapGet_HasDelegate_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (int o, string i $$
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_MultipleArgs_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (HttpContext context, int $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_SystemString_EndpointMapGet_HasDelegate_ReturnRouteParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (String $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_MultipleArgs_ParameterAlreadyUsed_EndpointMapGet_HasDelegate_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (string id, int $$
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_MultipleArgs_OneParameterAlreadyUsed_EndpointMapGet_HasDelegate_HasItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}/{id2}"", (string id, int $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id2", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_MultipleParameters_EndpointMapGet_HasDelegate_HasItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}/{id2}"", (string $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText),
+            i => Assert.Equal("id2", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_DuplicateParameters_EndpointMapGet_HasDelegate_HasItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}/{id}"", (string $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_MultipleArgs_ParameterAlreadyUsed_EndpointMapGet_HasCompleteDelegate_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (string id, int $$) => { });
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_MultipleArgs_ParameterAlreadyUsed_DifferentCase_EndpointMapGet_HasCompleteDelegate_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{ID}"", (string id, int $$) => { });
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_CustomParsableType_EndpointMapGet_HasDelegate_HasItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (CustomParsableType $$
+    }
+}
+
+public class CustomParsableType
+{
+    public static bool TryParse(string s, out CustomParsableType result)
+    {
+        result = new CustomParsableType();
+        return true;
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_CustomParsableWithFormatType_EndpointMapGet_HasDelegate_HasItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (CustomParsableType $$
+    }
+}
+
+public class CustomParsableType
+{
+    public static bool TryParse(string s, IFormatProvider provider, out CustomParsableType result)
+    {
+        result = new CustomParsableType();
+        return true;
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_CustomParsableWithFormatType_NonPublic_EndpointMapGet_HasDelegate_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (CustomParsableType $$
+    }
+}
+
+public class CustomParsableType
+{
+    private static bool TryParse(string s, IFormatProvider provider, out CustomParsableType result)
+    {
+        result = new CustomParsableType();
+        return true;
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_NonParsableType_EndpointMapGet_HasDelegate_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (NonParsableType $$
+    }
+}
+
+public interface NonParsableType
+{
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Theory]
+    [InlineData("int")]
+    [InlineData("decimal")]
+    [InlineData("DateTime")]
+    [InlineData("Guid")]
+    [InlineData("TimeSpan")]
+    [InlineData("string")]
+    [InlineData("String")]
+    [InlineData("string?")]
+    [InlineData("String?")]
+    [InlineData("Int32")]
+    [InlineData("int?")]
+    [InlineData("Nullable<int>")]
+    [InlineData("Nullable<Int32>")]
+    [InlineData("StringComparison")]
+    [InlineData("Uri")]
+    public async Task Insertion_Space_SupportedBuiltinTypes_EndpointMapGet_HasDelegate_HasItem(string parameterType)
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (" + parameterType + @" $$
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Theory]
+    [InlineData("HttpContext")]
+    [InlineData("CancellationToken")]
+    [InlineData("HttpRequest")]
+    [InlineData("HttpResponse")]
+    [InlineData("ClaimsPrincipal")]
+    [InlineData("IFormFileCollection")]
+    [InlineData("IFormFile")]
+    [InlineData("Stream")]
+    [InlineData("PipeReader")]
+    public async Task Insertion_Space_SpecialType_EndpointMapGet_HasDelegate_NoItems(string parameterType)
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.IO.Pipelines;
+using System.Security.Claims;
+using System.Threading;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", (" + parameterType + @" $$
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_EndpointMapGet_HasMethod_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", ExecuteGet $$);
+    }
+
+    static string ExecuteGet(string id)
+    {
+        return """";
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_EndpointMapGet_HasMethod_NamedParameters_ReturnDelegateParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(pattern: @""{id}"", endpoints: null, handler: (string blah, int $$)
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Theory]
+    [InlineData("AsParameters")]
+    [InlineData("FromQuery")]
+    [InlineData("FromForm")]
+    [InlineData("FromHeader")]
+    [InlineData("FromServices")]
+    public async Task Insertion_Space_EndpointMapGet_AsParameters_NoItem(string attributeName)
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", ([" + attributeName + @"] int $$) => {});
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_EndpointMapGet_UnknownAttribute_ReturnItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", ([PurpleMonkeyDishwasher] int $$) => {});
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_EndpointMapGet_NullDelegate_NoResults()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", null $$
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_EndpointMapGet_Incomplete_NoResults()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+
+class Program
+{
+    static void Main()
+    {
+        EndpointRouteBuilderExtensions.MapGet(null, @""{id}"", $$
+    }
+}
+");
+
+        // Assert
+        var item = result.Completions.ItemsList.FirstOrDefault(i => i.DisplayText == "id");
+        Assert.Null(item);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_CustomMapGet_ReturnDelegateParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+
+class Program
+{
+    static void Main()
+    {
+        MapCustomThing(null, @""{id}"", (string $$) => "");
+    }
+
+    static void MapCustomThing(IEndpointRouteBuilder endpoints, [StringSyntax(""Route"")] string pattern, Delegate delegate)
+    {
+    }
+}
+");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_CustomMapGet_NoRouteSyntax_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+
+class Program
+{
+    static void Main()
+    {
+        MapCustomThing(null, @""{id}"", (string $$) => "");
+    }
+
+    static void MapCustomThing(IEndpointRouteBuilder endpoints, string pattern, Delegate delegate)
+    {
+    }
+}
+");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_ControllerAction_HasParameter_ReturnActionParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+    using System;
+    using System.Diagnostics.CodeAnalysis;
+    using Microsoft.AspNetCore.Builder;
+    using Microsoft.AspNetCore.Mvc;
+
+    class Program
+    {
+        static void Main()
+        {
+        }
+    }
+
+    public class TestController
+    {
+        [HttpGet(@""{id}"")]
+        public object TestAction(int $$)
+        {
+            return null;
+        }
+    }
+    ");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_ControllerAction_HasParameter_Incomplete_ReturnActionParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+    using System;
+    using System.Diagnostics.CodeAnalysis;
+    using Microsoft.AspNetCore.Builder;
+    using Microsoft.AspNetCore.Mvc;
+
+    class Program
+    {
+        static void Main()
+        {
+        }
+    }
+
+    public class TestController
+    {
+        [HttpGet(@""{id}"")]
+        public object TestAction(int $$
+    }
+    ");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Invoke_ControllerAction_HasParameter_Incomplete_ReturnActionParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+    using System;
+    using System.Diagnostics.CodeAnalysis;
+    using Microsoft.AspNetCore.Builder;
+    using Microsoft.AspNetCore.Mvc;
+
+    class Program
+    {
+        static void Main()
+        {
+        }
+    }
+
+    public class TestController
+    {
+        [HttpGet(@""{id}"")]
+        public object TestAction(int [|i|]$$
+    }
+    ", CompletionTrigger.Invoke);
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+
+        var change = await result.Service.GetChangeAsync(result.Document, result.Completions.ItemsList[0]);
+        Assert.Equal("id", change.TextChange.NewText);
+        Assert.Equal(result.CompletionListSpan, change.TextChange.Span);
+    }
+
+    [Fact]
+    public async Task Insertion_ControllerAction_HasParameter_Incomplete_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+    using System;
+    using System.Diagnostics.CodeAnalysis;
+    using Microsoft.AspNetCore.Builder;
+    using Microsoft.AspNetCore.Mvc;
+
+    class Program
+    {
+        static void Main()
+        {
+        }
+    }
+
+    public class TestController
+    {
+        [HttpGet(@""{id}"")]
+        public object TestAction(int i $$
+    }
+    ");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    [Fact]
+    public async Task Insertion_Space_ControllerAction_HasParameter_BeforeComma_ReturnActionParameterItem()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+    using System;
+    using System.Diagnostics.CodeAnalysis;
+    using Microsoft.AspNetCore.Builder;
+    using Microsoft.AspNetCore.Mvc;
+
+    class Program
+    {
+        static void Main()
+        {
+        }
+    }
+
+    public class TestController
+    {
+        [HttpGet(@""{id}"")]
+        public object TestAction(int $$, string blah)
+        {
+            return null;
+        }
+    }
+    ");
+
+        // Assert
+        Assert.Collection(
+            result.Completions.ItemsList,
+            i => Assert.Equal("id", i.DisplayText));
+    }
+
+    [Fact]
+    public async Task Insertion_Space_NonControllerAction_HasParameter_NoItems()
+    {
+        // Arrange & Act
+        var result = await GetCompletionsAndServiceAsync(@"
+    using System;
+    using System.Diagnostics.CodeAnalysis;
+    using Microsoft.AspNetCore.Builder;
+    using Microsoft.AspNetCore.Mvc;
+
+    class Program
+    {
+        static void Main()
+        {
+        }
+    }
+
+    public class TestController
+    {
+        [HttpGet(@""{id}"")]
+        internal object TestAction(int $$)
+        {
+            return null;
+        }
+    }
+    ");
+
+        // Assert
+        Assert.Empty(result.Completions.ItemsList);
+    }
+
+    private Task<CompletionResult> GetCompletionsAndServiceAsync(string source, CompletionTrigger? completionTrigger = null)
+    {
+        return CompletionTestHelpers.GetCompletionsAndServiceAsync(Runner, source, completionTrigger);
+    }
+}

+ 4 - 1
src/Shared/ParameterBindingMethodCache.cs

@@ -67,6 +67,9 @@ internal sealed class ParameterBindingMethodCache
     [RequiresUnreferencedCode("Performs reflection on type hierarchy. This cannot be statically analyzed.")]
     public Func<ParameterExpression, Expression, Expression>? FindTryParseMethod(Type type)
     {
+        // This method is used to find TryParse methods from .NET types using reflection. It's used at app runtime.
+        // Routing analyzers also detect TryParse methods when calculating what types are valid in routes.
+        // Changes here to support new types should be reflected in analyzers.
         Func<ParameterExpression, Expression, Expression>? Finder(Type type)
         {
             MethodInfo? methodInfo;
@@ -410,7 +413,7 @@ internal sealed class ParameterBindingMethodCache
     {
         return TValue.BindAsync(httpContext, parameter);
     }
-        
+
     [RequiresUnreferencedCode("Performs reflection on type hierarchy. This cannot be statically analyzed.")]
     private MethodInfo? GetStaticMethodFromHierarchy(Type type, string name, Type[] parameterTypes, Func<MethodInfo, bool> validateReturnType)
     {