diff --git a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs index 02addb8e468b..f34254be0816 100644 --- a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs @@ -5,9 +5,11 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.CodeAnalysis.CSharp.Symbols; namespace Microsoft.AspNetCore.Builder { @@ -184,6 +186,23 @@ public static MinimalActionEndpointConventionBuilder Map( // Add MethodInfo as metadata to assist with OpenAPI generation for the endpoint. builder.Metadata.Add(action.Method); + // Methods defined in a top-level program are generated as statics so the delegate + // target will be null. Inline lambdas are compiler generated properties so they can + // be filtered that way. + if (action.Target == null || !TypeHelper.IsCompilerGenerated(action.Method.Name)) + { + if (GeneratedNameParser.TryParseLocalFunctionName(action.Method.Name, out var endpointName)) + { + builder.Metadata.Add(new EndpointNameMetadata(endpointName)); + builder.Metadata.Add(new RouteNameMetadata(endpointName)); + } + else + { + builder.Metadata.Add(new EndpointNameMetadata(action.Method.Name)); + builder.Metadata.Add(new RouteNameMetadata(action.Method.Name)); + } + } + // Add delegate attributes as metadata var attributes = action.Method.GetCustomAttributes(); diff --git a/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs b/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs index 24b9b3df6c88..4c29a1dce365 100644 --- a/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Linq; using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Builder diff --git a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj index 4c0c0a1a5443..dabaeffa8273 100644 --- a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj +++ b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj @@ -24,6 +24,10 @@ Microsoft.AspNetCore.Routing.RouteCollection + + + + diff --git a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs index 4dd5bbaa4569..6bb049c116aa 100644 --- a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs @@ -359,6 +359,71 @@ public void MapFallbackWithoutPath_BuildsEndpointWithLowestRouteOrder() Assert.Equal(int.MaxValue, routeEndpointBuilder.Order); } + [Fact] + // This test scenario simulates methods defined in a top-level program + // which are compiler generated. We currently do some manually parsing leveraging + // code in Roslyn to support this scenario. More info at https://github.com/dotnet/roslyn/issues/55651. + public void MapMethod_DoesNotEndpointNameForInnerMethod() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + string InnerGetString() => "TestString"; + _ = builder.MapDelete("/", InnerGetString); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + Assert.NotNull(endpointName); + Assert.Equal("InnerGetString", endpointName?.EndpointName); + } + + [Fact] + public void MapMethod_SetsEndpointNameForMethodGroup() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + _ = builder.MapDelete("/", GetString); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + Assert.NotNull(endpointName); + Assert.Equal("GetString", endpointName?.EndpointName); + } + + [Fact] + public void WithNameOverridesDefaultEndpointName() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + _ = builder.MapDelete("/", GetString).WithName("SomeCustomName"); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + Assert.NotNull(endpointName); + Assert.Equal("SomeCustomName", endpointName?.EndpointName); + } + + private string GetString() => "TestString"; + + [Fact] + public void MapMethod_DoesNotSetEndpointNameForLambda() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + _ = builder.MapDelete("/", () => { }); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + Assert.Null(endpointName); + } + class FromRoute : Attribute, IFromRouteMetadata { public string? Name { get; set; } diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index fc3f69701a1e..d6a93763e6e4 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -78,7 +78,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string // For now, put all methods defined the same declaring type together. string controllerName; - if (methodInfo.DeclaringType is not null && !IsCompilerGenerated(methodInfo.DeclaringType)) + if (methodInfo.DeclaringType is not null && !TypeHelper.IsCompilerGenerated(methodInfo.DeclaringType.Name, methodInfo.DeclaringType)) { controllerName = methodInfo.DeclaringType.Name; } @@ -363,11 +363,5 @@ private static void AddActionDescriptorEndpointMetadata( actionDescriptor.EndpointMetadata = new List(endpointMetadata); } } - - // The CompilerGeneratedAttribute doesn't always get added so we also check if the type name starts with "<" - // For example, "<>c" is a "declaring" type the C# compiler will generate without the attribute for a top-level lambda - // REVIEW: Is there a better way to do this? - private static bool IsCompilerGenerated(Type type) => - Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute)) || type.Name.StartsWith('<'); } } diff --git a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj index 4b86355d1087..a1170d9393e4 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj +++ b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Shared/RoslynUtils/GeneratedNameConstants.cs b/src/Shared/RoslynUtils/GeneratedNameConstants.cs new file mode 100644 index 000000000000..b771fb784464 --- /dev/null +++ b/src/Shared/RoslynUtils/GeneratedNameConstants.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// These sources are copied from https://github.com/dotnet/roslyn/blob/7d7bf0cc73e335390d73c9de6d7afd1e49605c9d/src/Compilers/CSharp/Portable/Symbols/Synthesized/GeneratedNameConstants.cs +// and exist to address the issues with extracting original method names for +// generated local functions. See https://github.com/dotnet/roslyn/issues/55651 +// for more info. +namespace Microsoft.CodeAnalysis.CSharp.Symbols +{ + internal static class GeneratedNameConstants + { + internal const char DotReplacementInTypeNames = '-'; + internal const string SynthesizedLocalNamePrefix = "CS$"; + internal const string SuffixSeparator = "__"; + internal const char LocalFunctionNameTerminator = '|'; + } +} \ No newline at end of file diff --git a/src/Shared/RoslynUtils/GeneratedNameKind.cs b/src/Shared/RoslynUtils/GeneratedNameKind.cs new file mode 100644 index 000000000000..262ebf5264d8 --- /dev/null +++ b/src/Shared/RoslynUtils/GeneratedNameKind.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +// These sources are copied from https://github.com/dotnet/roslyn/blob/7d7bf0cc73e335390d73c9de6d7afd1e49605c9d/src/Compilers/CSharp/Portable/Symbols/Synthesized/GeneratedNameKind.cs +// and exist to address the issues with extracting original method names for +// generated local functions. See https://github.com/dotnet/roslyn/issues/55651 +// for more info. +namespace Microsoft.CodeAnalysis.CSharp.Symbols +{ + internal enum GeneratedNameKind + { + None = 0, + + // Used by EE: + ThisProxyField = '4', + HoistedLocalField = '5', + DisplayClassLocalOrField = '8', + LambdaMethod = 'b', + LambdaDisplayClass = 'c', + StateMachineType = 'd', + LocalFunction = 'g', // note collision with Deprecated_InitializerLocal, however this one is only used for method names + + // Used by EnC: + AwaiterField = 'u', + HoistedSynthesizedLocalField = 's', + + // Currently not parsed: + StateMachineStateField = '1', + IteratorCurrentBackingField = '2', + StateMachineParameterProxyField = '3', + ReusableHoistedLocalField = '7', + LambdaCacheField = '9', + FixedBufferField = 'e', + AnonymousType = 'f', + TransparentIdentifier = 'h', + AnonymousTypeField = 'i', + AnonymousTypeTypeParameter = 'j', + AutoPropertyBackingField = 'k', + IteratorCurrentThreadIdField = 'l', + IteratorFinallyMethod = 'm', + BaseMethodWrapper = 'n', + AsyncBuilderField = 't', + DynamicCallSiteContainerType = 'o', + DynamicCallSiteField = 'p', + AsyncIteratorPromiseOfValueOrEndBackingField = 'v', + DisposeModeField = 'w', + CombinedTokensField = 'x', // last + + // Deprecated - emitted by Dev12, but not by Roslyn. + // Don't reuse the values because the debugger might encounter them when consuming old binaries. + [Obsolete] + Deprecated_OuterscopeLocals = '6', + [Obsolete] + Deprecated_IteratorInstance = 'a', + [Obsolete] + Deprecated_InitializerLocal = 'g', + [Obsolete] + Deprecated_DynamicDelegate = 'q', + [Obsolete] + Deprecated_ComrefCallLocal = 'r', + } + + internal static class GeneratedNameKindExtensions + { + internal static bool IsTypeName(this GeneratedNameKind kind) + => kind is GeneratedNameKind.LambdaDisplayClass or GeneratedNameKind.StateMachineType or GeneratedNameKind.DynamicCallSiteContainerType; + } +} \ No newline at end of file diff --git a/src/Shared/RoslynUtils/GeneratedNameParser.cs b/src/Shared/RoslynUtils/GeneratedNameParser.cs new file mode 100644 index 000000000000..ba6086484861 --- /dev/null +++ b/src/Shared/RoslynUtils/GeneratedNameParser.cs @@ -0,0 +1,170 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Globalization; + +// These sources are copied from https://github.com/dotnet/roslyn/blob/7d7bf0cc73e335390d73c9de6d7afd1e49605c9d/src/Compilers/CSharp/Portable/Symbols/Synthesized/GeneratedNameParser.cs +// and exist to address the issues with extracting original method names for +// generated local functions. See https://github.com/dotnet/roslyn/issues/55651 +// for more info. +namespace Microsoft.CodeAnalysis.CSharp.Symbols +{ + internal static class GeneratedNameParser + { + // Parse the generated name. Returns true for names of the form + // [CS$]<[middle]>c[__[suffix]] where [CS$] is included for certain + // generated names, where [middle] and [__[suffix]] are optional, + // and where c is a single character in [1-9a-z] + // (csharp\LanguageAnalysis\LIB\SpecialName.cpp). + internal static bool TryParseGeneratedName( + string name, + out GeneratedNameKind kind, + out int openBracketOffset, + out int closeBracketOffset) + { + openBracketOffset = -1; + if (name.StartsWith("CS$<", StringComparison.Ordinal)) + { + openBracketOffset = 3; + } + else if (name.StartsWith("<", StringComparison.Ordinal)) + { + openBracketOffset = 0; + } + + if (openBracketOffset >= 0) + { + closeBracketOffset = IndexOfBalancedParenthesis(name, openBracketOffset, '>'); + if (closeBracketOffset >= 0 && closeBracketOffset + 1 < name.Length) + { + int c = name[closeBracketOffset + 1]; + if (c is >= '1' and <= '9' or >= 'a' and <= 'z') // Note '0' is not special. + { + kind = (GeneratedNameKind)c; + return true; + } + } + } + + kind = GeneratedNameKind.None; + openBracketOffset = -1; + closeBracketOffset = -1; + return false; + } + + private static int IndexOfBalancedParenthesis(string str, int openingOffset, char closing) + { + char opening = str[openingOffset]; + + int depth = 1; + for (int i = openingOffset + 1; i < str.Length; i++) + { + var c = str[i]; + if (c == opening) + { + depth++; + } + else if (c == closing) + { + depth--; + if (depth == 0) + { + return i; + } + } + } + + return -1; + } + + internal static bool TryParseSourceMethodNameFromGeneratedName(string generatedName, GeneratedNameKind requiredKind, [NotNullWhen(true)] out string? methodName) + { + if (!TryParseGeneratedName(generatedName, out var kind, out int openBracketOffset, out int closeBracketOffset)) + { + methodName = null; + return false; + } + + if (requiredKind != 0 && kind != requiredKind) + { + methodName = null; + return false; + } + + methodName = generatedName.Substring(openBracketOffset + 1, closeBracketOffset - openBracketOffset - 1); + + if (kind.IsTypeName()) + { + methodName = methodName.Replace(GeneratedNameConstants.DotReplacementInTypeNames, '.'); + } + + return true; + } + + /// + /// Parses generated local function name out of a generated method name. + /// + internal static bool TryParseLocalFunctionName(string generatedName, [NotNullWhen(true)] out string? localFunctionName) + { + localFunctionName = null; + + // '<' containing-method-name '>' 'g' '__' local-function-name '|' method-ordinal '_' lambda-ordinal + if (!TryParseGeneratedName(generatedName, out var kind, out _, out int closeBracketOffset) || kind != GeneratedNameKind.LocalFunction) + { + return false; + } + + int localFunctionNameStart = closeBracketOffset + 2 + GeneratedNameConstants.SuffixSeparator.Length; + if (localFunctionNameStart >= generatedName.Length) + { + return false; + } + + int localFunctionNameEnd = generatedName.IndexOf(GeneratedNameConstants.LocalFunctionNameTerminator, localFunctionNameStart); + if (localFunctionNameEnd < 0) + { + return false; + } + + localFunctionName = generatedName.Substring(localFunctionNameStart, localFunctionNameEnd - localFunctionNameStart); + return true; + } + + // Extracts the slot index from a name of a field that stores hoisted variables or awaiters. + // Such a name ends with "__{slot index + 1}". + // Returned slot index is >= 0. + internal static bool TryParseSlotIndex(string fieldName, out int slotIndex) + { + int lastUnder = fieldName.LastIndexOf('_'); + if (lastUnder - 1 < 0 || lastUnder == fieldName.Length || fieldName[lastUnder - 1] != '_') + { + slotIndex = -1; + return false; + } + + if (int.TryParse(fieldName.AsSpan(lastUnder + 1), NumberStyles.None, CultureInfo.InvariantCulture, out slotIndex) && slotIndex >= 1) + { + slotIndex--; + return true; + } + + slotIndex = -1; + return false; + } + + internal static bool TryParseAnonymousTypeParameterName(string typeParameterName, [NotNullWhen(true)] out string? propertyName) + { + if (typeParameterName.StartsWith("<", StringComparison.Ordinal) && + typeParameterName.EndsWith(">j__TPar", StringComparison.Ordinal)) + { + propertyName = typeParameterName.Substring(1, typeParameterName.Length - 9); + return true; + } + + propertyName = null; + return false; + } + } +} \ No newline at end of file diff --git a/src/Shared/RoslynUtils/TypeHelper.cs b/src/Shared/RoslynUtils/TypeHelper.cs new file mode 100644 index 000000000000..1f81ee50526f --- /dev/null +++ b/src/Shared/RoslynUtils/TypeHelper.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.CompilerServices +{ + internal static class TypeHelper + { + /// + /// Checks to see if a given type is compiler generated. + /// + /// The compiler doesn't always annotate every time it generates with the + /// CompilerGeneratedAttribute so sometimes we have to check if the type's + /// identifier represents a generated type. Follows the same heuristics seen + /// in https://github.com/dotnet/roslyn/blob/b57c1f89c1483da8704cde7b535a20fd029748db/src/ExpressionEvaluator/Core/Source/ResultProvider/Helpers/GeneratedMetadataNames.cs#L19 + /// + /// + /// The type to evaluate. Can be null if evaluating only on name. + /// The identifier associated wit the type. + /// if is compiler generated + /// or represents a compiler generated identifier. + internal static bool IsCompilerGenerated(string name, Type? type = null) + { + return (type is Type && Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute))) + || name.StartsWith("<", StringComparison.Ordinal) + || (name.IndexOf('$') >= 0); + } + } +} \ No newline at end of file