Skip to content

Commit a8e2fcf

Browse files
authored
Add AK2005 - Must not use void async delegate in Command message handler (#123)
1 parent 58c1293 commit a8e2fcf

File tree

7 files changed

+889
-1
lines changed

7 files changed

+889
-1
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="MustNotUseVoidAsyncDelegateInReceiveFixer.cs" company="Akka.NET Project">
3+
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
7+
using System.Composition;
8+
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.CodeActions;
10+
using Microsoft.CodeAnalysis.CodeFixes;
11+
using Microsoft.CodeAnalysis.CSharp;
12+
using Microsoft.CodeAnalysis.CSharp.Syntax;
13+
14+
namespace Akka.Analyzers.Fixes;
15+
16+
[ExportCodeFixProvider(LanguageNames.CSharp)]
17+
[Shared]
18+
public class MustNotUseVoidAsyncDelegateInReceivePersistentActorCommandFixer()
19+
: BatchedCodeFixProvider(RuleDescriptors.Ak2005MustNotUseVoidAsyncDelegateInReceivePersistentActorCommand.Id)
20+
{
21+
public const string Key_FixCommandWithVoidAsyncDelegate = "AK2005_FixCommandWithVoidAsyncDelegate";
22+
23+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
24+
{
25+
// 1) Get the syntax root
26+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
27+
if (root is null)
28+
return;
29+
30+
// 2) Find the node at the diagnostic's span
31+
var diagnostic = context.Diagnostics.FirstOrDefault();
32+
if (diagnostic is null)
33+
return;
34+
var diagnosticSpan = diagnostic.Location.SourceSpan;
35+
var node = root.FindNode(diagnosticSpan);
36+
37+
// 3) Find the enclosing InvocationExpressionSyntax
38+
var invocation = node.FirstAncestorOrSelf<InvocationExpressionSyntax>();
39+
if (invocation == null)
40+
return;
41+
42+
// 4) Check if any argument is an async lambda. If not, skip registering a fix.
43+
var hasAsyncLambda = invocation.ArgumentList.Arguments
44+
.Any(a => a.Expression is LambdaExpressionSyntax lambda && lambda.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword));
45+
if (!hasAsyncLambda)
46+
return;
47+
48+
context.RegisterCodeFix(
49+
CodeAction.Create(
50+
title: "Replace Command with CommandAsync",
51+
createChangedDocument: c => ReplaceCommandWithReceiveAsync(context.Document, invocation, c),
52+
equivalenceKey: Key_FixCommandWithVoidAsyncDelegate),
53+
diagnostic);
54+
}
55+
56+
private static async Task<Document> ReplaceCommandWithReceiveAsync(Document document,
57+
InvocationExpressionSyntax invocation, CancellationToken cancellationToken)
58+
{
59+
// We want to keep the same type arguments (e.g. <int>) but rename "Command" → "CommandAsync".
60+
// invocation.Expression can be:
61+
// • GenericNameSyntax (e.g. Command<int>)
62+
// • MemberAccessExpressionSyntax whose .Name is a GenericNameSyntax (e.g. this.Command<int>)
63+
// • (rarely) a bare IdentifierNameSyntax
64+
65+
var expression = invocation.Expression;
66+
67+
var oldNameNode = expression switch
68+
{
69+
GenericNameSyntax genericName => (SimpleNameSyntax)genericName,
70+
MemberAccessExpressionSyntax { Name: GenericNameSyntax memberGeneric } => memberGeneric,
71+
IdentifierNameSyntax identifierName => identifierName,
72+
_ => null
73+
};
74+
75+
if(oldNameNode is null)
76+
{
77+
// If we don’t find a recognizable simple name, bail out.
78+
return document;
79+
}
80+
81+
// Build a new SimpleNameSyntax with identifier "CommandAsync", preserving any type arguments.
82+
SimpleNameSyntax newNameNode;
83+
if (oldNameNode is GenericNameSyntax oldGeneric)
84+
{
85+
newNameNode = SyntaxFactory.GenericName(SyntaxFactory.Identifier("CommandAsync"), oldGeneric.TypeArgumentList);
86+
}
87+
else
88+
{
89+
newNameNode = SyntaxFactory.IdentifierName("CommandAsync");
90+
}
91+
92+
// If the old expression was a member‐access (e.g. this.Command<int>), replace only the Name:
93+
SyntaxNode replacedExpression;
94+
if (expression is MemberAccessExpressionSyntax oldMemberAccess)
95+
{
96+
replacedExpression = oldMemberAccess.WithName(newNameNode);
97+
}
98+
else
99+
{
100+
// Otherwise, replace the entire expression (Command<int> → CommandAsync<int>)
101+
replacedExpression = newNameNode;
102+
}
103+
104+
// Create a new InvocationExpressionSyntax with the replaced expression
105+
var newInvocation = invocation.WithExpression((ExpressionSyntax)replacedExpression);
106+
107+
// Replace invocation in the syntax tree
108+
var oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
109+
var newRoot = oldRoot!.ReplaceNode(invocation, newInvocation);
110+
111+
return document.WithSyntaxRoot(newRoot);
112+
}
113+
}

0 commit comments

Comments
 (0)