Skip to content

Commit 1008a6a

Browse files
authored
Merge pull request #3 from PandaTechAM/development
lambda
2 parents efd7e2d + 66744d9 commit 1008a6a

File tree

3 files changed

+239
-10
lines changed

3 files changed

+239
-10
lines changed

src/Analyzers/Analyzers.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
<PackageId>Pandatech.Analyzers</PackageId>
1919
<PackageIcon>pandatech.png</PackageIcon>
2020
<PackageReadmeFile>Readme.md</PackageReadmeFile>
21-
<Version>1.2.0</Version>
21+
<Version>1.4.0</Version>
2222
<Authors>Pandatech</Authors>
2323
<Description>Pandatech Roslyn analyzers enforcing company coding rules.</Description>
2424
<PackageLicenseExpression>MIT</PackageLicenseExpression>
2525
<PackageTags>Pandatech, analyzers, roslyn, async, cancellation, coding-rules</PackageTags>
2626
<RepositoryUrl>https://github.com/PandaTechAM/be-lib-analyzers</RepositoryUrl>
27-
<PackageReleaseNotes>Bug fix on external interfaces</PackageReleaseNotes>
27+
<PackageReleaseNotes>Bug fix on lambdas</PackageReleaseNotes>
2828

2929

3030
</PropertyGroup>

src/Analyzers/Async/AsyncMethodConventionsAnalyzer.cs

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ public override void Initialize(AnalysisContext context)
6161

6262
context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method);
6363
context.RegisterOperationAction(AnalyzeAnonymousFunction, OperationKind.AnonymousFunction);
64+
65+
context.RegisterOperationAction(AnalyzeMinimalApiInvocation, OperationKind.Invocation);
6466
}
6567

6668
private static void AnalyzeMethod(SymbolAnalysisContext context)
@@ -117,6 +119,7 @@ private static void AnalyzeAnonymousFunction(OperationAnalysisContext context)
117119
var anon = (IAnonymousFunctionOperation)context.Operation;
118120
var symbol = anon.Symbol;
119121

122+
// Only Task / ValueTask
120123
if (symbol.ReturnType is not INamedTypeSymbol returnType)
121124
{
122125
return;
@@ -127,15 +130,43 @@ private static void AnalyzeAnonymousFunction(OperationAnalysisContext context)
127130
return;
128131
}
129132

133+
// For lambdas/anonymous functions we do NOT enforce "must have CT" (PT0002),
134+
// because the delegate signature is usually dictated by an external API
135+
// (Hangfire, ASP.NET, minimal APIs binder, etc.).
136+
// We only normalize name and position if a CT parameter already exists.
137+
var ctInfo = GetCancellationTokenInfo(symbol);
138+
if (!ctInfo.HasCt)
139+
{
140+
return;
141+
}
142+
130143
const string displayName = "anonymous function";
144+
var location = anon.Syntax.GetLocation();
131145

132-
AnalyzeAsyncMember(
133-
symbol,
134-
anon.Syntax.GetLocation(),
135-
displayName,
136-
context.ReportDiagnostic);
146+
// PT0003: name must be ct
147+
if (!ctInfo.IsNamedCt)
148+
{
149+
context.ReportDiagnostic(
150+
Diagnostic.Create(
151+
CancellationTokenNameRule,
152+
location,
153+
displayName,
154+
ctInfo.Name));
155+
}
156+
157+
// PT0004: CT must be last non-params parameter
158+
if (!ctInfo.IsLast)
159+
{
160+
context.ReportDiagnostic(
161+
Diagnostic.Create(
162+
CancellationTokenPositionRule,
163+
location,
164+
displayName,
165+
ctInfo.Name));
166+
}
137167
}
138168

169+
139170
private static void AnalyzeAsyncMember(IMethodSymbol method,
140171
Location location,
141172
string displayName,
@@ -240,6 +271,129 @@ private static CtInfo GetCancellationTokenInfo(IMethodSymbol method)
240271
return new CtInfo(true, isNamedCt, isLast, ctParam.Name);
241272
}
242273

274+
private static void AnalyzeMinimalApiInvocation(OperationAnalysisContext context)
275+
{
276+
var invocation = (IInvocationOperation)context.Operation;
277+
var target = invocation.TargetMethod;
278+
279+
// 1. Is this one of the Minimal API Map* extension methods?
280+
if (!IsMinimalApiMapMethod(target))
281+
{
282+
return;
283+
}
284+
285+
// 2. Find the handler lambda inside the arguments.
286+
// We do NOT rely on arg.Parameter.Type (which is often System.Delegate).
287+
IAnonymousFunctionOperation? handlerAnon = null;
288+
289+
foreach (var arg in invocation.Arguments)
290+
{
291+
handlerAnon = ExtractAnonymousFunction(arg.Value);
292+
if (handlerAnon is not null)
293+
{
294+
break;
295+
}
296+
}
297+
298+
if (handlerAnon is null)
299+
{
300+
// Handler is a method group or something non-lambda; skip.
301+
return;
302+
}
303+
304+
var handlerSymbol = handlerAnon.Symbol;
305+
306+
// Must be Task/ValueTask-based handler.
307+
if (handlerSymbol.ReturnType is not INamedTypeSymbol returnType ||
308+
!IsTaskLike(returnType))
309+
{
310+
return;
311+
}
312+
313+
// 3. Enforce: minimal API handler must have a CancellationToken parameter.
314+
var ctInfo = GetCancellationTokenInfo(handlerSymbol);
315+
if (ctInfo.HasCt)
316+
{
317+
// If CT already exists, PT0003/PT0004 are handled by AnalyzeAnonymousFunction.
318+
return;
319+
}
320+
321+
const string displayName = "anonymous function";
322+
323+
context.ReportDiagnostic(
324+
Diagnostic.Create(
325+
CancellationTokenMissingRule,
326+
handlerAnon.Syntax.GetLocation(),
327+
displayName));
328+
}
329+
330+
331+
private static bool IsMinimalApiMapMethod(IMethodSymbol method)
332+
{
333+
if (!method.IsExtensionMethod)
334+
{
335+
return false;
336+
}
337+
338+
// EndpointRouteBuilderExtensions.MapGet / MapPost / MapPut / MapDelete / MapMethods / ...
339+
var containingType = method.ContainingType;
340+
if (containingType is null)
341+
{
342+
return false;
343+
}
344+
345+
if (!string.Equals(containingType.Name, "EndpointRouteBuilderExtensions", StringComparison.Ordinal))
346+
{
347+
return false;
348+
}
349+
350+
var ns = containingType.ContainingNamespace.ToDisplayString();
351+
if (!ns.StartsWith("Microsoft.AspNetCore.Builder", StringComparison.Ordinal))
352+
{
353+
return false;
354+
}
355+
356+
// Limit to the typical Map* names
357+
return method.Name is
358+
"MapGet" or
359+
"MapPost" or
360+
"MapPut" or
361+
"MapDelete" or
362+
"MapPatch" or
363+
"MapHead" or
364+
"MapOptions" or
365+
"MapTrace" or
366+
"MapMethods";
367+
}
368+
369+
370+
private static IAnonymousFunctionOperation? ExtractAnonymousFunction(IOperation value)
371+
{
372+
while (true)
373+
{
374+
switch (value)
375+
{
376+
case IAnonymousFunctionOperation anon:
377+
return anon;
378+
379+
case IDelegateCreationOperation
380+
{
381+
Target: IAnonymousFunctionOperation anon
382+
}:
383+
return anon;
384+
385+
case IConversionOperation
386+
{
387+
Operand: var operand
388+
}:
389+
value = operand;
390+
continue;
391+
392+
default:
393+
return null;
394+
}
395+
}
396+
}
243397

244398
private readonly struct CtInfo(bool hasCt, bool isNamedCt, bool isLast, string name)
245399
{

test/Analyzers.Tests/AsyncMethodConventionsAnalyzerTests.cs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ public class Service
170170
}
171171

172172
[Fact]
173-
public async Task Anonymous_lambda_missing_ct_reports_PT0002()
173+
public async Task Anonymous_lambda_without_ct_is_ignored_when_delegate_has_no_ct()
174174
{
175175
const string code = """
176176
using System;
@@ -180,14 +180,89 @@ public class Service
180180
{
181181
public void Register()
182182
{
183-
Func<Task> handler = {|PT0002:async () =>
183+
Func<Task> handler = async () =>
184184
{
185185
await Task.Delay(10);
186-
}|};
186+
};
187187
}
188188
}
189189
""";
190190

191191
await VerifyCS.VerifyAnalyzerAsync(code);
192192
}
193+
194+
195+
[Fact]
196+
public async Task Hangfire_expression_lambda_is_ignored()
197+
{
198+
const string code = """
199+
using System;
200+
using System.Threading;
201+
using System.Threading.Tasks;
202+
203+
public interface IUserManagementIntegrationService
204+
{
205+
Task UpdateAuthenticationHistoryMissingLocationsAsync(CancellationToken ct);
206+
}
207+
208+
public static class Jobs
209+
{
210+
public static void Register()
211+
{
212+
RecurringJob.AddOrUpdate<IUserManagementIntegrationService>(
213+
"Update Authentication History Missing Locations",
214+
service => service.UpdateAuthenticationHistoryMissingLocationsAsync(CancellationToken.None),
215+
"0 * * * *",
216+
TimeZoneInfo.Utc);
217+
}
218+
}
219+
220+
public static class RecurringJob
221+
{
222+
public static void AddOrUpdate<T>(string id, System.Linq.Expressions.Expression<Func<T, Task>> method, string cron, TimeZoneInfo tz) { }
223+
}
224+
""";
225+
226+
await VerifyCS.VerifyAnalyzerAsync(code);
227+
}
228+
229+
[Fact]
230+
public async Task RequestDelegate_lambda_is_ignored()
231+
{
232+
const string code = """
233+
using System.Threading.Tasks;
234+
235+
public static class Extensions
236+
{
237+
public static void Configure(IEndpointConventionBuilder builder)
238+
{
239+
builder.Add(endpointBuilder =>
240+
{
241+
var original = endpointBuilder.RequestDelegate;
242+
endpointBuilder.RequestDelegate = async context =>
243+
{
244+
await original!(context);
245+
};
246+
});
247+
}
248+
}
249+
250+
public interface IEndpointConventionBuilder
251+
{
252+
void Add(System.Action<EndpointBuilder> convention);
253+
}
254+
255+
public class EndpointBuilder
256+
{
257+
public RequestDelegate? RequestDelegate { get; set; }
258+
}
259+
260+
// Fake ASP.NET-like types just for the test
261+
public class HttpContext { }
262+
263+
public delegate Task RequestDelegate(HttpContext context);
264+
""";
265+
266+
await VerifyCS.VerifyAnalyzerAsync(code);
267+
}
193268
}

0 commit comments

Comments
 (0)