Skip to content

Commit c5fa42f

Browse files
authored
Supoort local functions via Roslyn parsing logic
1 parent 704673f commit c5fa42f

9 files changed

+297
-14
lines changed

src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.AspNetCore.Http;
1010
using Microsoft.AspNetCore.Routing;
1111
using Microsoft.AspNetCore.Routing.Patterns;
12+
using Microsoft.CodeAnalysis.CSharp.Symbols;
1213

1314
namespace Microsoft.AspNetCore.Builder
1415
{
@@ -185,16 +186,21 @@ public static MinimalActionEndpointConventionBuilder Map(
185186
// Add MethodInfo as metadata to assist with OpenAPI generation for the endpoint.
186187
builder.Metadata.Add(action.Method);
187188

188-
// We only add endpoint names for types that are not compiler generated since
189-
// compiler generated types are mangled by default. This logic can be changed once
190-
// https://github.com/dotnet/roslyn/issues/55651 is addressed. For now, this will
191-
// not set the endpoint name metadata for:
192-
// - Local functions
193-
// - Inline lambdas
194-
// - Static functions
195-
if (!TypeHelper.IsCompilerGenerated(action.Method.Name))
189+
// Methods defined in a top-level program are generated as statics so the delegate
190+
// target will be null. Inline lambdas are compiler generated properties so they can
191+
// be filtered that way.
192+
if (action.Target == null || !TypeHelper.IsCompilerGenerated(action.Method.Name))
196193
{
197-
builder.Metadata.Add(new EndpointNameMetadata(action.Method.Name));
194+
if (GeneratedNameParser.TryParseLocalFunctionName(action.Method.Name, out var endpointName))
195+
{
196+
builder.Metadata.Add(new EndpointNameMetadata(endpointName));
197+
builder.Metadata.Add(new RouteNameMetadata(endpointName));
198+
}
199+
else
200+
{
201+
builder.Metadata.Add(new EndpointNameMetadata(action.Method.Name));
202+
builder.Metadata.Add(new RouteNameMetadata(action.Method.Name));
203+
}
198204
}
199205

200206
// Add delegate attributes as metadata

src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Linq;
56
using Microsoft.AspNetCore.Routing;
67

78
namespace Microsoft.AspNetCore.Builder

src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ Microsoft.AspNetCore.Routing.RouteCollection</Description>
2424

2525
<ItemGroup>
2626
<Compile Include="$(SharedSourceRoot)PropertyHelper\*.cs" />
27-
<Compile Include="$(SharedSourceRoot)TypeHelper.cs" />
27+
<Compile Include="$(SharedSourceRoot)RoslynUtils\TypeHelper.cs" />
28+
<Compile Include="$(SharedSourceRoot)RoslynUtils\GeneratedNameParser.cs" />
29+
<Compile Include="$(SharedSourceRoot)RoslynUtils\GeneratedNameKind.cs" />
30+
<Compile Include="$(SharedSourceRoot)RoslynUtils\GeneratedNameConstants.cs" />
2831
</ItemGroup>
2932

3033
<ItemGroup>

src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,8 @@ public void MapFallbackWithoutPath_BuildsEndpointWithLowestRouteOrder()
361361

362362
[Fact]
363363
// This test scenario simulates methods defined in a top-level program
364-
// which are compiler generated. This can be re-examined once
365-
// https://github.com/dotnet/roslyn/issues/55651 is addressed.
364+
// which are compiler generated. We currently do some manually parsing leveraging
365+
// code in Roslyn to support this scenario. More info at https://github.com/dotnet/roslyn/issues/55651.
366366
public void MapMethod_DoesNotEndpointNameForInnerMethod()
367367
{
368368
var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier()));
@@ -374,7 +374,8 @@ public void MapMethod_DoesNotEndpointNameForInnerMethod()
374374
var endpoint = Assert.Single(dataSource.Endpoints);
375375

376376
var endpointName = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
377-
Assert.Null(endpointName);
377+
Assert.NotNull(endpointName);
378+
Assert.Equal("InnerGetString", endpointName?.EndpointName);
378379
}
379380

380381
[Fact]
@@ -392,6 +393,21 @@ public void MapMethod_SetsEndpointNameForMethodGroup()
392393
Assert.Equal("GetString", endpointName?.EndpointName);
393394
}
394395

396+
[Fact]
397+
public void WithNameOverridesDefaultEndpointName()
398+
{
399+
var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier()));
400+
_ = builder.MapDelete("/", GetString).WithName("SomeCustomName");
401+
402+
var dataSource = GetBuilderEndpointDataSource(builder);
403+
// Trigger Endpoint build by calling getter.
404+
var endpoint = Assert.Single(dataSource.Endpoints);
405+
406+
var endpointName = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
407+
Assert.NotNull(endpointName);
408+
Assert.Equal("SomeCustomName", endpointName?.EndpointName);
409+
}
410+
395411
private string GetString() => "TestString";
396412

397413
[Fact]

src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
<ItemGroup>
1313
<Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" />
14-
<Compile Include="$(SharedSourceRoot)TypeHelper.cs" />
14+
<Compile Include="$(SharedSourceRoot)RoslynUtils\TypeHelper.cs" />
1515
</ItemGroup>
1616

1717
<ItemGroup>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
// These sources are copied from https://github.com/dotnet/roslyn/blob/7d7bf0cc73e335390d73c9de6d7afd1e49605c9d/src/Compilers/CSharp/Portable/Symbols/Synthesized/GeneratedNameConstants.cs
5+
// and exist to address the issues with extracting original method names for
6+
// generated local functions. See https://github.com/dotnet/roslyn/issues/55651
7+
// for more info.
8+
namespace Microsoft.CodeAnalysis.CSharp.Symbols
9+
{
10+
internal static class GeneratedNameConstants
11+
{
12+
internal const char DotReplacementInTypeNames = '-';
13+
internal const string SynthesizedLocalNamePrefix = "CS$";
14+
internal const string SuffixSeparator = "__";
15+
internal const char LocalFunctionNameTerminator = '|';
16+
}
17+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
6+
// These sources are copied from https://github.com/dotnet/roslyn/blob/7d7bf0cc73e335390d73c9de6d7afd1e49605c9d/src/Compilers/CSharp/Portable/Symbols/Synthesized/GeneratedNameKind.cs
7+
// and exist to address the issues with extracting original method names for
8+
// generated local functions. See https://github.com/dotnet/roslyn/issues/55651
9+
// for more info.
10+
namespace Microsoft.CodeAnalysis.CSharp.Symbols
11+
{
12+
internal enum GeneratedNameKind
13+
{
14+
None = 0,
15+
16+
// Used by EE:
17+
ThisProxyField = '4',
18+
HoistedLocalField = '5',
19+
DisplayClassLocalOrField = '8',
20+
LambdaMethod = 'b',
21+
LambdaDisplayClass = 'c',
22+
StateMachineType = 'd',
23+
LocalFunction = 'g', // note collision with Deprecated_InitializerLocal, however this one is only used for method names
24+
25+
// Used by EnC:
26+
AwaiterField = 'u',
27+
HoistedSynthesizedLocalField = 's',
28+
29+
// Currently not parsed:
30+
StateMachineStateField = '1',
31+
IteratorCurrentBackingField = '2',
32+
StateMachineParameterProxyField = '3',
33+
ReusableHoistedLocalField = '7',
34+
LambdaCacheField = '9',
35+
FixedBufferField = 'e',
36+
AnonymousType = 'f',
37+
TransparentIdentifier = 'h',
38+
AnonymousTypeField = 'i',
39+
AnonymousTypeTypeParameter = 'j',
40+
AutoPropertyBackingField = 'k',
41+
IteratorCurrentThreadIdField = 'l',
42+
IteratorFinallyMethod = 'm',
43+
BaseMethodWrapper = 'n',
44+
AsyncBuilderField = 't',
45+
DynamicCallSiteContainerType = 'o',
46+
DynamicCallSiteField = 'p',
47+
AsyncIteratorPromiseOfValueOrEndBackingField = 'v',
48+
DisposeModeField = 'w',
49+
CombinedTokensField = 'x', // last
50+
51+
// Deprecated - emitted by Dev12, but not by Roslyn.
52+
// Don't reuse the values because the debugger might encounter them when consuming old binaries.
53+
[Obsolete]
54+
Deprecated_OuterscopeLocals = '6',
55+
[Obsolete]
56+
Deprecated_IteratorInstance = 'a',
57+
[Obsolete]
58+
Deprecated_InitializerLocal = 'g',
59+
[Obsolete]
60+
Deprecated_DynamicDelegate = 'q',
61+
[Obsolete]
62+
Deprecated_ComrefCallLocal = 'r',
63+
}
64+
65+
internal static class GeneratedNameKindExtensions
66+
{
67+
internal static bool IsTypeName(this GeneratedNameKind kind)
68+
=> kind is GeneratedNameKind.LambdaDisplayClass or GeneratedNameKind.StateMachineType or GeneratedNameKind.DynamicCallSiteContainerType;
69+
}
70+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Globalization;
7+
8+
// These sources are copied from https://github.com/dotnet/roslyn/blob/7d7bf0cc73e335390d73c9de6d7afd1e49605c9d/src/Compilers/CSharp/Portable/Symbols/Synthesized/GeneratedNameParser.cs
9+
// and exist to address the issues with extracting original method names for
10+
// generated local functions. See https://github.com/dotnet/roslyn/issues/55651
11+
// for more info.
12+
namespace Microsoft.CodeAnalysis.CSharp.Symbols
13+
{
14+
internal static class GeneratedNameParser
15+
{
16+
// Parse the generated name. Returns true for names of the form
17+
// [CS$]<[middle]>c[__[suffix]] where [CS$] is included for certain
18+
// generated names, where [middle] and [__[suffix]] are optional,
19+
// and where c is a single character in [1-9a-z]
20+
// (csharp\LanguageAnalysis\LIB\SpecialName.cpp).
21+
internal static bool TryParseGeneratedName(
22+
string name,
23+
out GeneratedNameKind kind,
24+
out int openBracketOffset,
25+
out int closeBracketOffset)
26+
{
27+
openBracketOffset = -1;
28+
if (name.StartsWith("CS$<", StringComparison.Ordinal))
29+
{
30+
openBracketOffset = 3;
31+
}
32+
else if (name.StartsWith("<", StringComparison.Ordinal))
33+
{
34+
openBracketOffset = 0;
35+
}
36+
37+
if (openBracketOffset >= 0)
38+
{
39+
closeBracketOffset = IndexOfBalancedParenthesis(name, openBracketOffset, '>');
40+
if (closeBracketOffset >= 0 && closeBracketOffset + 1 < name.Length)
41+
{
42+
int c = name[closeBracketOffset + 1];
43+
if (c is >= '1' and <= '9' or >= 'a' and <= 'z') // Note '0' is not special.
44+
{
45+
kind = (GeneratedNameKind)c;
46+
return true;
47+
}
48+
}
49+
}
50+
51+
kind = GeneratedNameKind.None;
52+
openBracketOffset = -1;
53+
closeBracketOffset = -1;
54+
return false;
55+
}
56+
57+
private static int IndexOfBalancedParenthesis(string str, int openingOffset, char closing)
58+
{
59+
char opening = str[openingOffset];
60+
61+
int depth = 1;
62+
for (int i = openingOffset + 1; i < str.Length; i++)
63+
{
64+
var c = str[i];
65+
if (c == opening)
66+
{
67+
depth++;
68+
}
69+
else if (c == closing)
70+
{
71+
depth--;
72+
if (depth == 0)
73+
{
74+
return i;
75+
}
76+
}
77+
}
78+
79+
return -1;
80+
}
81+
82+
internal static bool TryParseSourceMethodNameFromGeneratedName(string generatedName, GeneratedNameKind requiredKind, [NotNullWhen(true)] out string? methodName)
83+
{
84+
if (!TryParseGeneratedName(generatedName, out var kind, out int openBracketOffset, out int closeBracketOffset))
85+
{
86+
methodName = null;
87+
return false;
88+
}
89+
90+
if (requiredKind != 0 && kind != requiredKind)
91+
{
92+
methodName = null;
93+
return false;
94+
}
95+
96+
methodName = generatedName.Substring(openBracketOffset + 1, closeBracketOffset - openBracketOffset - 1);
97+
98+
if (kind.IsTypeName())
99+
{
100+
methodName = methodName.Replace(GeneratedNameConstants.DotReplacementInTypeNames, '.');
101+
}
102+
103+
return true;
104+
}
105+
106+
/// <summary>
107+
/// Parses generated local function name out of a generated method name.
108+
/// </summary>
109+
internal static bool TryParseLocalFunctionName(string generatedName, [NotNullWhen(true)] out string? localFunctionName)
110+
{
111+
localFunctionName = null;
112+
113+
// '<' containing-method-name '>' 'g' '__' local-function-name '|' method-ordinal '_' lambda-ordinal
114+
if (!TryParseGeneratedName(generatedName, out var kind, out _, out int closeBracketOffset) || kind != GeneratedNameKind.LocalFunction)
115+
{
116+
return false;
117+
}
118+
119+
int localFunctionNameStart = closeBracketOffset + 2 + GeneratedNameConstants.SuffixSeparator.Length;
120+
if (localFunctionNameStart >= generatedName.Length)
121+
{
122+
return false;
123+
}
124+
125+
int localFunctionNameEnd = generatedName.IndexOf(GeneratedNameConstants.LocalFunctionNameTerminator, localFunctionNameStart);
126+
if (localFunctionNameEnd < 0)
127+
{
128+
return false;
129+
}
130+
131+
localFunctionName = generatedName.Substring(localFunctionNameStart, localFunctionNameEnd - localFunctionNameStart);
132+
return true;
133+
}
134+
135+
// Extracts the slot index from a name of a field that stores hoisted variables or awaiters.
136+
// Such a name ends with "__{slot index + 1}".
137+
// Returned slot index is >= 0.
138+
internal static bool TryParseSlotIndex(string fieldName, out int slotIndex)
139+
{
140+
int lastUnder = fieldName.LastIndexOf('_');
141+
if (lastUnder - 1 < 0 || lastUnder == fieldName.Length || fieldName[lastUnder - 1] != '_')
142+
{
143+
slotIndex = -1;
144+
return false;
145+
}
146+
147+
if (int.TryParse(fieldName.AsSpan(lastUnder + 1), NumberStyles.None, CultureInfo.InvariantCulture, out slotIndex) && slotIndex >= 1)
148+
{
149+
slotIndex--;
150+
return true;
151+
}
152+
153+
slotIndex = -1;
154+
return false;
155+
}
156+
157+
internal static bool TryParseAnonymousTypeParameterName(string typeParameterName, [NotNullWhen(true)] out string? propertyName)
158+
{
159+
if (typeParameterName.StartsWith("<", StringComparison.Ordinal) &&
160+
typeParameterName.EndsWith(">j__TPar", StringComparison.Ordinal))
161+
{
162+
propertyName = typeParameterName.Substring(1, typeParameterName.Length - 9);
163+
return true;
164+
}
165+
166+
propertyName = null;
167+
return false;
168+
}
169+
}
170+
}
File renamed without changes.

0 commit comments

Comments
 (0)