Skip to content

Commit b3d1817

Browse files
Copilotandrewlock
andauthored
Add analyzer for Enum.GetNames() with generated alternative (#209)
* Initial plan * Add GetNamesAnalyzer, GetNamesCodeFixProvider, and comprehensive tests Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Tweaks to GetNames * Add analyzer tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> Co-authored-by: Andrew Lock <andrewlock.net@gmail.com>
1 parent 4256862 commit b3d1817

5 files changed

Lines changed: 669 additions & 1 deletion

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 NetEscapades.EnumGenerators.Diagnostics.UsageAnalyzers;
8+
9+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
10+
public class GetNamesAnalyzer : DiagnosticAnalyzer
11+
{
12+
public const string DiagnosticId = "NEEG008";
13+
public static readonly DiagnosticDescriptor Rule = new(
14+
#pragma warning disable RS2008 // Enable Analyzer Release Tracking
15+
id: DiagnosticId,
16+
#pragma warning restore RS2008
17+
title: "Use generated GetNames() instead of Enum.GetNames()",
18+
messageFormat: "Use generated GetNames() instead of Enum.GetNames() for better performance on enum '{0}'",
19+
category: "Usage",
20+
defaultSeverity: DiagnosticSeverity.Info,
21+
isEnabledByDefault: true);
22+
23+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
24+
=> ImmutableArray.Create(Rule);
25+
26+
public override void Initialize(AnalysisContext context)
27+
{
28+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
29+
context.EnableConcurrentExecution();
30+
context.RegisterCompilationStartAction(ctx =>
31+
{
32+
var (enumExtensionsAttr, externalEnumTypes) = AnalyzerHelpers.GetEnumExtensionAttributes(ctx.Compilation);
33+
if (enumExtensionsAttr is null || externalEnumTypes is null)
34+
{
35+
return;
36+
}
37+
38+
ctx.RegisterSyntaxNodeAction(
39+
c => AnalyzeInvocation(c, enumExtensionsAttr, externalEnumTypes),
40+
SyntaxKind.InvocationExpression);
41+
});
42+
}
43+
44+
private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, INamedTypeSymbol enumExtensionsAttr, ExternalEnumDictionary externalEnumTypes)
45+
{
46+
var invocation = (InvocationExpressionSyntax)context.Node;
47+
48+
if (invocation.ArgumentList.Arguments.Count is not (0 or 1)
49+
|| invocation.Expression is not MemberAccessExpressionSyntax memberAccess
50+
|| memberAccess.Name.Identifier.Text != nameof(Enum.GetNames))
51+
{
52+
// can't be the one we want
53+
return;
54+
}
55+
56+
// Get the symbol information for the invocation
57+
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
58+
if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
59+
{
60+
return;
61+
}
62+
63+
// Verify this is the GetNames() method from System.Enum
64+
if (methodSymbol.Name != nameof(Enum.GetNames) ||
65+
methodSymbol.ContainingType.SpecialType != SpecialType.System_Enum)
66+
{
67+
return;
68+
}
69+
70+
ITypeSymbol? enumType = null;
71+
72+
// Handle two basic patterns:
73+
// 1. Enum.GetNames(typeof(TEnum)) - has 1 parameter
74+
// 2. Enum.GetNames<TEnum>() - has 0 parameters, is generic
75+
if (methodSymbol is { IsGenericMethod: true, TypeArguments.Length: 1 })
76+
{
77+
// Pattern: Enum.GetNames<TEnum>()
78+
if (invocation.ArgumentList.Arguments.Count is not 0)
79+
{
80+
return;
81+
}
82+
83+
enumType = methodSymbol.TypeArguments[0];
84+
}
85+
else if (methodSymbol.Parameters.Length is 1
86+
&& invocation.ArgumentList.Arguments is [{ Expression: TypeOfExpressionSyntax typeOfExpression }])
87+
{
88+
// Pattern: Enum.GetNames(typeof(TEnum))
89+
enumType = context.SemanticModel.GetTypeInfo(typeOfExpression.Type).Type;
90+
}
91+
92+
if (enumType is null || enumType.TypeKind != TypeKind.Enum)
93+
{
94+
return;
95+
}
96+
97+
if (!AnalyzerHelpers.IsEnumWithExtensions(enumType, enumExtensionsAttr, externalEnumTypes, out var extensionType))
98+
{
99+
return;
100+
}
101+
102+
// Report the diagnostic
103+
var diagnostic = Diagnostic.Create(
104+
descriptor: Rule,
105+
location: invocation.GetLocation(),
106+
messageArgs: enumType.Name,
107+
properties: ImmutableDictionary.CreateRange<string, string?>([
108+
new(AnalyzerHelpers.ExtensionTypeNameProperty, extensionType),
109+
]));
110+
111+
context.ReportDiagnostic(diagnostic);
112+
}
113+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CodeActions;
5+
using Microsoft.CodeAnalysis.CodeFixes;
6+
using Microsoft.CodeAnalysis.CSharp.Syntax;
7+
using Microsoft.CodeAnalysis.Editing;
8+
using Microsoft.CodeAnalysis.Simplification;
9+
10+
namespace NetEscapades.EnumGenerators.Diagnostics.UsageAnalyzers;
11+
12+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(GetNamesCodeFixProvider)), Shared]
13+
public class GetNamesCodeFixProvider : CodeFixProviderBase
14+
{
15+
private const string Title = "Replace with generated GetNames()";
16+
17+
public sealed override ImmutableArray<string> FixableDiagnosticIds
18+
=> ImmutableArray.Create(GetNamesAnalyzer.DiagnosticId);
19+
20+
public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
21+
{
22+
if (!context.Diagnostics.IsDefaultOrEmpty)
23+
{
24+
// Register a code action for GetNames() replacement
25+
context.RegisterCodeFix(
26+
CodeAction.Create(
27+
title: Title,
28+
createChangedDocument: c => FixAllAsync(context.Document, context.Diagnostics, c),
29+
equivalenceKey: Title),
30+
context.Diagnostics);
31+
}
32+
33+
return Task.CompletedTask;
34+
}
35+
36+
protected override Task FixWithEditor(DocumentEditor editor, Diagnostic diagnostic,
37+
INamedTypeSymbol extensionTypeSymbol,
38+
CancellationToken cancellationToken)
39+
{
40+
// Find the invocation node at the diagnostic location
41+
var node = editor.OriginalRoot.FindNode(diagnostic.Location.SourceSpan);
42+
if (node is not InvocationExpressionSyntax invocation)
43+
{
44+
return Task.CompletedTask;
45+
}
46+
47+
// Create new invocation: ExtensionsClass.GetNames()
48+
var generator = editor.Generator;
49+
var newInvocation = generator.InvocationExpression(
50+
generator.MemberAccessExpression(generator.TypeExpression(extensionTypeSymbol), "GetNames"))
51+
.WithTriviaFrom(invocation)
52+
.WithAdditionalAnnotations(Simplifier.AddImportsAnnotation, Simplifier.Annotation);
53+
54+
editor.ReplaceNode(invocation, newInvocation);
55+
return Task.CompletedTask;
56+
}
57+
}

tests/NetEscapades.EnumGenerators.IntegrationTests/.editorconfig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ dotnet_diagnostic.NEEG003.severity = error
77
dotnet_diagnostic.NEEG004.severity = error
88
dotnet_diagnostic.NEEG005.severity = error
99
dotnet_diagnostic.NEEG006.severity = error
10-
dotnet_diagnostic.NEEG007.severity = error
10+
dotnet_diagnostic.NEEG007.severity = error
11+
dotnet_diagnostic.NEEG008.severity = error

tests/NetEscapades.EnumGenerators.IntegrationTests/AnalyzerTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,15 @@ public void Neeg007Testing()
9999
#endif
100100
#pragma warning restore NEEG007
101101
}
102+
103+
[Fact]
104+
public void Neeg008Testing()
105+
{
106+
#pragma warning disable NEEG008
107+
_ = Enum.GetNames(typeof(FlagsEnum));
108+
#if NET5_0_OR_GREATER
109+
_ = Enum.GetNames<FlagsEnum>().Length;
110+
#endif
111+
#pragma warning restore NEEG008
112+
}
102113
}

0 commit comments

Comments
 (0)