Skip to content

Commit 59e7014

Browse files
committed
feat: add analyzer
1 parent 29f496a commit 59e7014

6 files changed

Lines changed: 638 additions & 0 deletions

File tree

src/Mediator.SourceGenerator/Mediator.SourceGenerator.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<ItemGroup>
1818
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
1919
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" PrivateAssets="all" />
20+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.3.0" PrivateAssets="all" />
2021
<PackageReference Include="Nullable" Version="1.3.1" PrivateAssets="all" />
2122
<PackageReference Include="IsExternalInit" Version="1.0.2" PrivateAssets="all" />
2223
</ItemGroup>
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace Zapto.Mediator.Generator;
8+
9+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
10+
public class UseTypedSenderExtensionAnalyzer : DiagnosticAnalyzer
11+
{
12+
public const string DiagnosticId = "ZM0001";
13+
public const string ExtensionMethodNameProperty = "ExtensionMethodName";
14+
public const string HasNamespaceArgProperty = "HasNamespaceArg";
15+
public const string IsObjectCreationProperty = "IsObjectCreation";
16+
17+
internal static readonly DiagnosticDescriptor Rule = new(
18+
DiagnosticId,
19+
title: "Use typed sender extension method",
20+
messageFormat: "Use '{0}' instead",
21+
category: "Usage",
22+
defaultSeverity: DiagnosticSeverity.Info,
23+
isEnabledByDefault: true,
24+
description: "Use the typed extension method generated for this request type instead of the generic Send/Publish/CreateStream overload.");
25+
26+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
27+
ImmutableArray.Create(Rule);
28+
29+
public override void Initialize(AnalysisContext context)
30+
{
31+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
32+
context.EnableConcurrentExecution();
33+
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
34+
}
35+
36+
private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
37+
{
38+
var invocation = (InvocationExpressionSyntax)context.Node;
39+
40+
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
41+
return;
42+
43+
var methodIdentifier = memberAccess.Name.Identifier.ValueText;
44+
if (methodIdentifier is not ("Send" or "Publish" or "CreateStream"))
45+
return;
46+
47+
if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol methodSymbol)
48+
return;
49+
50+
var containingType = methodSymbol.ContainingType;
51+
if (!IsMediatorInterface(containingType))
52+
return;
53+
54+
var args = invocation.ArgumentList.Arguments;
55+
if (args.Count == 0) return;
56+
57+
// Named outer arguments can cause incorrect argument reordering in the code fix
58+
foreach (var arg in args)
59+
{
60+
if (arg.NameColon != null) return;
61+
}
62+
63+
// The first argument might be a MediatorNamespace for namespaced calls
64+
var requestArgIndex = 0;
65+
var firstArgType = context.SemanticModel.GetTypeInfo(args[0].Expression).Type;
66+
if (firstArgType?.Name == "MediatorNamespace" &&
67+
firstArgType.ContainingNamespace?.ToDisplayString() == "Zapto.Mediator")
68+
{
69+
requestArgIndex = 1;
70+
}
71+
72+
if (args.Count <= requestArgIndex) return;
73+
74+
var requestArg = args[requestArgIndex];
75+
if (context.SemanticModel.GetTypeInfo(requestArg.Expression).Type is not INamedTypeSymbol requestType)
76+
return;
77+
78+
var interfaceName = FindRequestInterface(requestType);
79+
if (interfaceName is null) return;
80+
81+
var extensionMethodName = ComputeExtensionMethodName(interfaceName, requestType.Name, methodSymbol);
82+
83+
if (!ExtensionMethodExists(context.Compilation, requestType, extensionMethodName))
84+
return;
85+
86+
// Don't unwrap generic types (type arguments may not be inferable from ctor args alone)
87+
// Don't unwrap when an object initializer is also present (it would be silently dropped)
88+
var isObjectCreation = !requestType.IsGenericType &&
89+
requestArg.Expression is ObjectCreationExpressionSyntax oc &&
90+
oc.ArgumentList?.Arguments.Count > 0 &&
91+
oc.Initializer == null;
92+
var hasNamespaceArg = requestArgIndex > 0;
93+
94+
var properties = ImmutableDictionary.CreateBuilder<string, string?>();
95+
properties[ExtensionMethodNameProperty] = extensionMethodName;
96+
properties[HasNamespaceArgProperty] = hasNamespaceArg ? "true" : "false";
97+
properties[IsObjectCreationProperty] = isObjectCreation ? "true" : "false";
98+
99+
var diagnostic = Diagnostic.Create(
100+
Rule,
101+
memberAccess.Name.GetLocation(),
102+
properties.ToImmutable(),
103+
extensionMethodName);
104+
105+
context.ReportDiagnostic(diagnostic);
106+
}
107+
108+
private static bool IsMediatorInterface(INamedTypeSymbol type)
109+
{
110+
return type.ContainingNamespace?.ToDisplayString() == "Zapto.Mediator" &&
111+
type.Name is "ISender" or "IPublisher" or "IBackgroundPublisher";
112+
}
113+
114+
internal static string? FindRequestInterface(INamedTypeSymbol requestType)
115+
{
116+
foreach (var iface in requestType.AllInterfaces)
117+
{
118+
var ns = iface.ContainingNamespace?.ToDisplayString();
119+
if ((ns == "Zapto.Mediator" || ns == "MediatR") &&
120+
iface.Name is "IRequest" or "INotification" or "IStreamRequest")
121+
{
122+
return iface.Name;
123+
}
124+
}
125+
return null;
126+
}
127+
128+
internal static string ComputeExtensionMethodName(string interfaceName, string typeName, IMethodSymbol? methodSymbol = null)
129+
{
130+
// Remove the "I" prefix to get suffix: IRequest -> Request, INotification -> Notification
131+
var suffix = interfaceName.Substring(1);
132+
var baseName = typeName.EndsWith(suffix) && typeName.Length != suffix.Length
133+
? typeName.Substring(0, typeName.Length - suffix.Length)
134+
: typeName;
135+
136+
// IBackgroundPublisher methods are void, so no "Async" suffix
137+
var isVoidReturn = methodSymbol?.ReturnType.SpecialType == SpecialType.System_Void;
138+
return isVoidReturn ? baseName : baseName + "Async";
139+
}
140+
141+
internal static bool ExtensionMethodExists(Compilation compilation, INamedTypeSymbol requestType, string methodName)
142+
{
143+
var isGlobalNs = requestType.ContainingNamespace?.IsGlobalNamespace ?? true;
144+
var ns = isGlobalNs ? null : requestType.ContainingNamespace?.ToDisplayString();
145+
var fullTypeName = ns is null ? "SenderExtensions" : $"{ns}.SenderExtensions";
146+
147+
var extensionsType = compilation.GetTypeByMetadataName(fullTypeName);
148+
if (extensionsType is null) return false;
149+
150+
foreach (var member in extensionsType.GetMembers(methodName))
151+
{
152+
if (member is IMethodSymbol) return true;
153+
}
154+
155+
return false;
156+
}
157+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CodeActions;
7+
using Microsoft.CodeAnalysis.CodeFixes;
8+
using Microsoft.CodeAnalysis.CSharp;
9+
using Microsoft.CodeAnalysis.CSharp.Syntax;
10+
11+
namespace Zapto.Mediator.Generator;
12+
13+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseTypedSenderExtensionCodeFixProvider)), Shared]
14+
public class UseTypedSenderExtensionCodeFixProvider : CodeFixProvider
15+
{
16+
public override ImmutableArray<string> FixableDiagnosticIds =>
17+
ImmutableArray.Create(UseTypedSenderExtensionAnalyzer.DiagnosticId);
18+
19+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
20+
21+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
22+
{
23+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
24+
if (root is null) return;
25+
26+
var diagnostic = context.Diagnostics[0];
27+
if (!diagnostic.Properties.TryGetValue(UseTypedSenderExtensionAnalyzer.ExtensionMethodNameProperty, out var methodName) || methodName is null)
28+
return;
29+
30+
var nameNode = root.FindNode(diagnostic.Location.SourceSpan) as SimpleNameSyntax;
31+
var memberAccess = nameNode?.Parent as MemberAccessExpressionSyntax;
32+
var invocation = memberAccess?.Parent as InvocationExpressionSyntax;
33+
if (invocation is null) return;
34+
35+
context.RegisterCodeFix(
36+
CodeAction.Create(
37+
title: $"Use '{methodName}'",
38+
createChangedDocument: ct => ApplyFixAsync(context.Document, invocation, methodName, diagnostic.Properties, ct),
39+
equivalenceKey: UseTypedSenderExtensionAnalyzer.DiagnosticId),
40+
diagnostic);
41+
}
42+
43+
private static async Task<Document> ApplyFixAsync(
44+
Document document,
45+
InvocationExpressionSyntax invocation,
46+
string methodName,
47+
ImmutableDictionary<string, string?> properties,
48+
CancellationToken cancellationToken)
49+
{
50+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
51+
if (root is null) return document;
52+
53+
var hasNamespaceArg = properties.TryGetValue(UseTypedSenderExtensionAnalyzer.HasNamespaceArgProperty, out var nsStr) && nsStr == "true";
54+
var isObjectCreation = properties.TryGetValue(UseTypedSenderExtensionAnalyzer.IsObjectCreationProperty, out var ocStr) && ocStr == "true";
55+
56+
var requestArgIndex = hasNamespaceArg ? 1 : 0;
57+
var args = invocation.ArgumentList.Arguments;
58+
var newArgs = new System.Collections.Generic.List<ArgumentSyntax>();
59+
60+
// Preserve the optional namespace argument
61+
if (hasNamespaceArg)
62+
{
63+
newArgs.Add(args[0].WithoutTrivia().WithLeadingTrivia(args[0].GetLeadingTrivia()));
64+
}
65+
66+
var requestArg = args[requestArgIndex];
67+
68+
if (isObjectCreation && requestArg.Expression is ObjectCreationExpressionSyntax oc && oc.ArgumentList is not null)
69+
{
70+
// Unwrap constructor arguments: Send(new Req(a, b)) -> ReqAsync(a, b)
71+
foreach (var ctorArg in oc.ArgumentList.Arguments)
72+
{
73+
newArgs.Add(ctorArg);
74+
}
75+
}
76+
else
77+
{
78+
// Keep the request expression as-is: Send(req) -> ReqAsync(req)
79+
newArgs.Add(requestArg);
80+
}
81+
82+
// Preserve trailing arguments (e.g. CancellationToken)
83+
for (var i = requestArgIndex + 1; i < args.Count; i++)
84+
{
85+
newArgs.Add(args[i]);
86+
}
87+
88+
var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
89+
var newMemberAccess = memberAccess.WithName(
90+
SyntaxFactory.IdentifierName(methodName)
91+
.WithTriviaFrom(memberAccess.Name));
92+
93+
var newInvocation = invocation
94+
.WithExpression(newMemberAccess)
95+
.WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(newArgs)));
96+
97+
var newRoot = root.ReplaceNode(invocation, newInvocation);
98+
return document.WithSyntaxRoot(newRoot);
99+
}
100+
}

tests/Mediator.DependencyInjection.Tests/Mediator.DependencyInjection.Tests.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
99
</PropertyGroup>
1010

11+
<ItemGroup>
12+
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-73j8-2gch-69rq" />
13+
</ItemGroup>
14+
1115
<ItemGroup>
1216
<ProjectReference Include="..\..\src\Mediator.DependencyInjection\Mediator.DependencyInjection.csproj" />
1317
<ProjectReference Include="..\..\src\Mediator\Mediator.csproj" />

0 commit comments

Comments
 (0)