Skip to content

Commit 018c68e

Browse files
Copilotarika0093
andcommitted
Add LQRF003 analyzer and code fix with tests
Co-authored-by: arika0093 <4524647+arika0093@users.noreply.github.com>
1 parent 2876a02 commit 018c68e

File tree

4 files changed

+1165
-0
lines changed

4 files changed

+1165
-0
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
using System.Linq;
2+
using Linqraft.Core.AnalyzerHelpers;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Diagnostics;
7+
8+
namespace Linqraft.Analyzer;
9+
10+
/// <summary>
11+
/// Analyzer that detects void/Task methods with Select using anonymous types that can be converted to API response methods.
12+
/// </summary>
13+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
14+
public class ApiResponseMethodGeneratorAnalyzer : BaseLinqraftAnalyzer
15+
{
16+
public const string AnalyzerId = "LQRF003";
17+
18+
private static readonly DiagnosticDescriptor RuleInstance = new(
19+
AnalyzerId,
20+
"Method can be converted to API response method",
21+
"Method '{0}' can be converted to an async API response method",
22+
"Design",
23+
DiagnosticSeverity.Info,
24+
isEnabledByDefault: true,
25+
description: "This void/Task method contains a Select with anonymous type that can be converted to an async API response method.",
26+
helpLinkUri: $"https://github.com/arika0093/Linqraft/blob/main/docs/analyzer/{AnalyzerId}.md"
27+
);
28+
29+
protected override string DiagnosticId => AnalyzerId;
30+
protected override LocalizableString Title => RuleInstance.Title;
31+
protected override LocalizableString MessageFormat => RuleInstance.MessageFormat;
32+
protected override LocalizableString Description => RuleInstance.Description;
33+
protected override DiagnosticSeverity Severity => DiagnosticSeverity.Info;
34+
protected override DiagnosticDescriptor Rule => RuleInstance;
35+
36+
protected override void RegisterActions(AnalysisContext context)
37+
{
38+
context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration);
39+
}
40+
41+
private static void AnalyzeMethod(SyntaxNodeAnalysisContext context)
42+
{
43+
var methodDeclaration = (MethodDeclarationSyntax)context.Node;
44+
45+
// Check if the method has void or Task return type
46+
if (!IsVoidOrTaskReturnType(methodDeclaration, context.SemanticModel))
47+
{
48+
return;
49+
}
50+
51+
// Find Select calls with anonymous types that are not assigned to variables
52+
var selectInvocation = FindUnassignedSelectWithAnonymousType(methodDeclaration);
53+
if (selectInvocation == null)
54+
{
55+
return;
56+
}
57+
58+
// Verify it's on IQueryable
59+
if (!IsIQueryableSelect(selectInvocation, context.SemanticModel, context.CancellationToken))
60+
{
61+
return;
62+
}
63+
64+
// Report diagnostic at the method identifier location
65+
var diagnostic = Diagnostic.Create(
66+
RuleInstance,
67+
methodDeclaration.Identifier.GetLocation(),
68+
methodDeclaration.Identifier.Text
69+
);
70+
context.ReportDiagnostic(diagnostic);
71+
}
72+
73+
private static bool IsVoidOrTaskReturnType(
74+
MethodDeclarationSyntax methodDeclaration,
75+
SemanticModel semanticModel
76+
)
77+
{
78+
var returnTypeInfo = semanticModel.GetTypeInfo(methodDeclaration.ReturnType);
79+
var returnType = returnTypeInfo.Type;
80+
81+
if (returnType == null)
82+
{
83+
return false;
84+
}
85+
86+
// Check for void
87+
if (returnType.SpecialType == SpecialType.System_Void)
88+
{
89+
return true;
90+
}
91+
92+
// Check for Task (non-generic)
93+
if (
94+
returnType.Name == "Task"
95+
&& returnType is INamedTypeSymbol namedType
96+
&& !namedType.IsGenericType
97+
)
98+
{
99+
return true;
100+
}
101+
102+
return false;
103+
}
104+
105+
private static InvocationExpressionSyntax? FindUnassignedSelectWithAnonymousType(
106+
MethodDeclarationSyntax methodDeclaration
107+
)
108+
{
109+
if (methodDeclaration.Body == null && methodDeclaration.ExpressionBody == null)
110+
{
111+
return null;
112+
}
113+
114+
// Find all invocation expressions in the method
115+
var invocations = methodDeclaration.DescendantNodes().OfType<InvocationExpressionSyntax>();
116+
117+
foreach (var invocation in invocations)
118+
{
119+
// Check if this is a Select call
120+
if (!IsSelectInvocation(invocation.Expression))
121+
{
122+
continue;
123+
}
124+
125+
// Check if the lambda contains an anonymous type
126+
var anonymousType = FindAnonymousTypeInArguments(invocation.ArgumentList);
127+
if (anonymousType == null)
128+
{
129+
continue;
130+
}
131+
132+
// Check if the invocation is not assigned to a variable
133+
// The invocation should be in an expression statement (standalone)
134+
// or in an await expression that is in an expression statement
135+
if (IsUnassignedInvocation(invocation))
136+
{
137+
return invocation;
138+
}
139+
}
140+
141+
return null;
142+
}
143+
144+
private static bool IsUnassignedInvocation(InvocationExpressionSyntax invocation)
145+
{
146+
var parent = invocation.Parent;
147+
148+
// Handle await expression
149+
if (parent is AwaitExpressionSyntax awaitExpr)
150+
{
151+
parent = awaitExpr.Parent;
152+
}
153+
154+
// Check if it's in an expression statement (standalone)
155+
return parent is ExpressionStatementSyntax;
156+
}
157+
158+
private static bool IsSelectInvocation(ExpressionSyntax expression)
159+
{
160+
return expression switch
161+
{
162+
MemberAccessExpressionSyntax memberAccess
163+
=> memberAccess.Name.Identifier.Text == "Select",
164+
IdentifierNameSyntax identifier => identifier.Identifier.Text == "Select",
165+
_ => false,
166+
};
167+
}
168+
169+
private static bool IsIQueryableSelect(
170+
InvocationExpressionSyntax invocation,
171+
SemanticModel semanticModel,
172+
System.Threading.CancellationToken cancellationToken
173+
)
174+
{
175+
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
176+
{
177+
return false;
178+
}
179+
180+
// Get the type of the expression before .Select()
181+
var typeInfo = semanticModel.GetTypeInfo(memberAccess.Expression, cancellationToken);
182+
var type = typeInfo.Type;
183+
184+
if (type == null)
185+
{
186+
return false;
187+
}
188+
189+
// Check if it's IQueryable<T> or implements IQueryable<T>
190+
if (type is INamedTypeSymbol namedType)
191+
{
192+
// Check if it's IQueryable<T> itself
193+
var displayString = namedType.OriginalDefinition.ToDisplayString();
194+
if (displayString.StartsWith("System.Linq.IQueryable<"))
195+
{
196+
return true;
197+
}
198+
199+
// Check if it implements IQueryable<T>
200+
foreach (var iface in namedType.AllInterfaces)
201+
{
202+
var ifaceDisplayString = iface.OriginalDefinition.ToDisplayString();
203+
if (ifaceDisplayString.StartsWith("System.Linq.IQueryable<"))
204+
{
205+
return true;
206+
}
207+
}
208+
}
209+
210+
return false;
211+
}
212+
213+
private static AnonymousObjectCreationExpressionSyntax? FindAnonymousTypeInArguments(
214+
ArgumentListSyntax argumentList
215+
)
216+
{
217+
foreach (var argument in argumentList.Arguments)
218+
{
219+
// Look for lambda expressions
220+
var lambda = argument.Expression switch
221+
{
222+
SimpleLambdaExpressionSyntax simple => simple.Body,
223+
ParenthesizedLambdaExpressionSyntax paren => paren.Body,
224+
_ => null,
225+
};
226+
227+
if (lambda is AnonymousObjectCreationExpressionSyntax anonymousObject)
228+
{
229+
return anonymousObject;
230+
}
231+
}
232+
233+
return null;
234+
}
235+
}

0 commit comments

Comments
 (0)