Skip to content

Commit 5cc8b1a

Browse files
Copilotandrewlock
andauthored
Add analyzer to detect HasFlag() and suggest HasFlagFast() replacement (#199)
* Initial plan * Add HasFlagAnalyzer and HasFlagCodeFixProvider with tests Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Address code review feedback - add using System; and fix formatting Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Extract some common code * Fix compilation * Disable analyzers in integration 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 a468975 commit 5cc8b1a

9 files changed

Lines changed: 917 additions & 49 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using Microsoft.CodeAnalysis;
2+
3+
namespace NetEscapades.EnumGenerators.Diagnostics;
4+
5+
public static class AnalyzerHelpers
6+
{
7+
public static (INamedTypeSymbol? enumExtensionsAttr, HashSet<INamedTypeSymbol>? externalEnumTypes) GetEnumExtensionAttributes(Compilation compilation)
8+
{
9+
var enumExtensionsAttr =
10+
compilation.GetTypeByMetadataName(Attributes.EnumExtensionsAttribute);
11+
var externalEnumExtensionsAttr =
12+
compilation.GetTypeByMetadataName(Attributes.ExternalEnumExtensionsAttribute);
13+
14+
if (enumExtensionsAttr is null)
15+
{
16+
return (enumExtensionsAttr, null);
17+
}
18+
19+
// Collect all enum types that have EnumExtensions<T> attributes
20+
var externalEnumTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
21+
if (externalEnumExtensionsAttr is not null)
22+
{
23+
foreach (var attribute in compilation.Assembly.GetAttributes())
24+
{
25+
if (attribute.AttributeClass is { IsGenericType: true } attrClass &&
26+
SymbolEqualityComparer.Default.Equals(attrClass.ConstructedFrom, externalEnumExtensionsAttr) &&
27+
attrClass.TypeArguments is [INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType])
28+
{
29+
externalEnumTypes.Add(enumType);
30+
}
31+
}
32+
}
33+
34+
return (enumExtensionsAttr, externalEnumTypes);
35+
}
36+
37+
public static bool IsEnumWithExtensions(
38+
ITypeSymbol receiverType,
39+
INamedTypeSymbol enumExtensionsAttr,
40+
HashSet<INamedTypeSymbol> externalEnumTypes)
41+
{
42+
// Check if the enum has the [EnumExtensions] attribute or is referenced in EnumExtensions<T>
43+
// First check if the enum itself has the attribute
44+
foreach (var attributeData in receiverType.GetAttributes())
45+
{
46+
if (SymbolEqualityComparer.Default.Equals(
47+
attributeData.AttributeClass,
48+
enumExtensionsAttr))
49+
{
50+
return true;
51+
}
52+
}
53+
54+
// If not, check if it's in the external enum types (EnumExtensions<T>)
55+
return receiverType is INamedTypeSymbol namedType
56+
&& externalEnumTypes.Contains(namedType);
57+
}
58+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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;
8+
9+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
10+
public class HasFlagAnalyzer : DiagnosticAnalyzer
11+
{
12+
public const string DiagnosticId = "NEEG005";
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 HasFlagFast() instead of HasFlag()",
18+
messageFormat: "Use HasFlagFast() instead of HasFlag() 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, HashSet<INamedTypeSymbol> externalEnumTypes)
45+
{
46+
var invocation = (InvocationExpressionSyntax)context.Node;
47+
48+
// Check if this is a member access expression (e.g., value.HasFlag())
49+
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
50+
{
51+
return;
52+
}
53+
54+
// Check if the method name is "HasFlag"
55+
if (memberAccess.Name.Identifier.Text != "HasFlag")
56+
{
57+
return;
58+
}
59+
60+
// Check if there is exactly one argument
61+
if (invocation.ArgumentList.Arguments.Count != 1)
62+
{
63+
return;
64+
}
65+
66+
// Get the symbol information for the invocation
67+
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
68+
if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
69+
{
70+
return;
71+
}
72+
73+
// Verify this is the HasFlag() method from System.Enum
74+
if (methodSymbol.Name != "HasFlag" ||
75+
methodSymbol.Parameters.Length != 1 ||
76+
methodSymbol.ContainingType.SpecialType != SpecialType.System_Enum)
77+
{
78+
return;
79+
}
80+
81+
// Get the type of the receiver (the thing before .HasFlag())
82+
var receiverType = context.SemanticModel.GetTypeInfo(memberAccess.Expression).Type;
83+
if (receiverType is null || receiverType.TypeKind != TypeKind.Enum)
84+
{
85+
return;
86+
}
87+
88+
if (!AnalyzerHelpers.IsEnumWithExtensions(receiverType, enumExtensionsAttr, externalEnumTypes))
89+
{
90+
return;
91+
}
92+
93+
// Report the diagnostic
94+
var diagnostic = Diagnostic.Create(
95+
Rule,
96+
memberAccess.Name.GetLocation(),
97+
receiverType.Name);
98+
99+
context.ReportDiagnostic(diagnostic);
100+
}
101+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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;
7+
using Microsoft.CodeAnalysis.CSharp.Syntax;
8+
9+
namespace NetEscapades.EnumGenerators.Diagnostics;
10+
11+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(HasFlagCodeFixProvider)), Shared]
12+
public class HasFlagCodeFixProvider : CodeFixProvider
13+
{
14+
private const string Title = "Replace with HasFlagFast()";
15+
16+
public sealed override ImmutableArray<string> FixableDiagnosticIds
17+
=> ImmutableArray.Create(HasFlagAnalyzer.DiagnosticId);
18+
19+
public sealed override FixAllProvider GetFixAllProvider()
20+
=> WellKnownFixAllProviders.BatchFixer;
21+
22+
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
23+
{
24+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
25+
if (root is null)
26+
{
27+
return;
28+
}
29+
30+
var diagnostic = context.Diagnostics.First();
31+
var diagnosticSpan = diagnostic.Location.SourceSpan;
32+
33+
// Find the node at the diagnostic location
34+
var node = root.FindNode(diagnosticSpan);
35+
36+
// Check if this is a HasFlag invocation
37+
if (node is IdentifierNameSyntax identifierName)
38+
{
39+
// Register a code action for HasFlag() replacement
40+
context.RegisterCodeFix(
41+
CodeAction.Create(
42+
title: Title,
43+
createChangedDocument: c => ReplaceHasFlagWithHasFlagFast(context.Document, identifierName, c),
44+
equivalenceKey: Title),
45+
context.Diagnostics);
46+
}
47+
}
48+
49+
private static async Task<Document> ReplaceHasFlagWithHasFlagFast(
50+
Document document,
51+
IdentifierNameSyntax identifierName,
52+
CancellationToken cancellationToken)
53+
{
54+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
55+
if (root is null)
56+
{
57+
return document;
58+
}
59+
60+
// Create the new identifier with "HasFlagFast"
61+
var newIdentifier = SyntaxFactory.IdentifierName("HasFlagFast")
62+
.WithTriviaFrom(identifierName);
63+
64+
// Create new member access with the new identifier
65+
var memberAccess = identifierName.Parent as MemberAccessExpressionSyntax;
66+
if (memberAccess is null)
67+
{
68+
return document;
69+
}
70+
71+
var newMemberAccess = memberAccess.WithName(newIdentifier);
72+
73+
// Replace the old member access with the new one
74+
var newRoot = root.ReplaceNode(memberAccess, newMemberAccess);
75+
76+
return document.WithSyntaxRoot(newRoot);
77+
}
78+
}

src/NetEscapades.EnumGenerators/Diagnostics/ToStringAnalyzer.cs

Lines changed: 4 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
using System;
2-
using System.Collections.Generic;
31
using System.Collections.Immutable;
42
using Microsoft.CodeAnalysis;
53
using Microsoft.CodeAnalysis.CSharp;
@@ -31,31 +29,12 @@ public override void Initialize(AnalysisContext context)
3129
context.EnableConcurrentExecution();
3230
context.RegisterCompilationStartAction(ctx =>
3331
{
34-
var enumExtensionsAttr =
35-
ctx.Compilation.GetTypeByMetadataName(Attributes.EnumExtensionsAttribute);
36-
var externalEnumExtensionsAttr =
37-
ctx.Compilation.GetTypeByMetadataName(Attributes.ExternalEnumExtensionsAttribute);
38-
39-
if (enumExtensionsAttr is null)
32+
var (enumExtensionsAttr, externalEnumTypes) = AnalyzerHelpers.GetEnumExtensionAttributes(ctx.Compilation);
33+
if (enumExtensionsAttr is null || externalEnumTypes is null)
4034
{
4135
return;
4236
}
4337

44-
// Collect all enum types that have EnumExtensions<T> attributes
45-
var externalEnumTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
46-
if (externalEnumExtensionsAttr is not null)
47-
{
48-
foreach (var attribute in ctx.Compilation.Assembly.GetAttributes())
49-
{
50-
if (attribute.AttributeClass is { IsGenericType: true } attrClass &&
51-
SymbolEqualityComparer.Default.Equals(attrClass.ConstructedFrom, externalEnumExtensionsAttr) &&
52-
attrClass.TypeArguments is [INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType])
53-
{
54-
externalEnumTypes.Add(enumType);
55-
}
56-
}
57-
}
58-
5938
ctx.RegisterSyntaxNodeAction(
6039
c => AnalyzeInvocation(c, enumExtensionsAttr, externalEnumTypes),
6140
SyntaxKind.InvocationExpression);
@@ -136,7 +115,7 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, INamedT
136115
return;
137116
}
138117

139-
if (!IsEnumWithExtensions(receiverType, enumExtensionsAttr, externalEnumTypes))
118+
if (!AnalyzerHelpers.IsEnumWithExtensions(receiverType, enumExtensionsAttr, externalEnumTypes))
140119
{
141120
return;
142121
}
@@ -190,7 +169,7 @@ private static void AnalyzeInterpolation(SyntaxNodeAnalysisContext context, INam
190169
}
191170
}
192171

193-
if (!IsEnumWithExtensions(expressionType, enumExtensionsAttr, externalEnumTypes))
172+
if (!AnalyzerHelpers.IsEnumWithExtensions(expressionType, enumExtensionsAttr, externalEnumTypes))
194173
{
195174
return;
196175
}
@@ -203,27 +182,4 @@ private static void AnalyzeInterpolation(SyntaxNodeAnalysisContext context, INam
203182

204183
context.ReportDiagnostic(diagnostic);
205184
}
206-
207-
private static bool IsEnumWithExtensions(
208-
ITypeSymbol receiverType,
209-
INamedTypeSymbol enumExtensionsAttr,
210-
HashSet<INamedTypeSymbol> externalEnumTypes)
211-
{
212-
// Check if the enum has the [EnumExtensions] attribute or is referenced in EnumExtensions<T>
213-
// First check if the enum itself has the attribute
214-
foreach (var attributeData in receiverType.GetAttributes())
215-
{
216-
if (SymbolEqualityComparer.Default.Equals(
217-
attributeData.AttributeClass,
218-
enumExtensionsAttr))
219-
{
220-
return true;
221-
}
222-
}
223-
224-
// If not, check if it's in the external enum types (EnumExtensions<T>)
225-
return receiverType is INamedTypeSymbol namedType
226-
&& externalEnumTypes.Contains(namedType);
227-
}
228-
229185
}

tests/NetEscapades.EnumGenerators.IntegrationTests/.editorconfig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
dotnet_diagnostic.NEEG001.severity = error
55
dotnet_diagnostic.NEEG002.severity = error
66
dotnet_diagnostic.NEEG003.severity = error
7-
dotnet_diagnostic.NEEG004.severity = error
7+
dotnet_diagnostic.NEEG004.severity = error
8+
dotnet_diagnostic.NEEG005.severity = error

tests/NetEscapades.EnumGenerators.IntegrationTests/AnalyzerTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
using System;
22
using Xunit;
33

4+
#if INTEGRATION_TESTS
5+
using NetEscapades.EnumGenerators.IntegrationTests;
6+
#elif NETSTANDARD_INTEGRATION_TESTS
7+
using NetEscapades.EnumGenerators.NetStandard.IntegrationTests;
8+
#elif NETSTANDARD_SYSTEMMEMORY_INTEGRATION_TESTS
9+
using NetEscapades.EnumGenerators.NetStandard.SystemMemory.IntegrationTests;
10+
#elif INTERCEPTOR_TESTS
11+
using NetEscapades.EnumGenerators.Interceptors.IntegrationTests;
12+
#elif NUGET_INTEGRATION_TESTS
13+
using NetEscapades.EnumGenerators.Nuget.IntegrationTests;
14+
#elif NUGET_INTERCEPTOR_TESTS
15+
using NetEscapades.EnumGenerators.Nuget.Interceptors.IntegrationTests;
16+
#elif NUGET_NETSTANDARD_INTERCEPTOR_TESTS
17+
using NetEscapades.EnumGenerators.Nuget.NetStandard.Interceptors.IntegrationTests;
18+
#elif NUGET_SYSTEMMEMORY_INTEGRATION_TESTS
19+
using NetEscapades.EnumGenerators.Nuget.SystemMemory.IntegrationTests;
20+
#else
21+
#error Unknown integration tests
22+
#endif
23+
424
namespace NetEscapades.EnumGenerators.Benchmarks;
525

626
public class AnalyzerTests
@@ -24,4 +44,14 @@ public void Neeg004Testing()
2444
_ = $"Some value: {EnumInSystem.First:G} <-";
2545
#pragma warning restore NEEG004
2646
}
47+
48+
[Fact]
49+
public void Neeg005Testing()
50+
{
51+
#pragma warning disable NEEG005
52+
var test = FlagsEnum.First;
53+
_ = test.HasFlag(FlagsEnum.Second);
54+
_ = $"Some value: {test.HasFlag(FlagsEnum.Second)} <-";
55+
#pragma warning restore NEEG005
56+
}
2757
}

tests/NetEscapades.EnumGenerators.IntegrationTests/ExternalFlagsEnumExtensionsTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ namespace NetEscapades.EnumGenerators.Nuget.SystemMemory.IntegrationTests;
2323
#error Unknown integration tests
2424
#endif
2525

26+
#pragma warning disable NEEG001
27+
#pragma warning disable NEEG002
28+
#pragma warning disable NEEG003
29+
#pragma warning disable NEEG004
30+
#pragma warning disable NEEG005
31+
2632
public class ExternalFileShareExtensionsTests : ExtensionTests<FileShare, int, ExternalFileShareExtensionsTests>, ITestData<FileShare>
2733
{
2834
public TheoryData<FileShare> ValidEnumValues() => new()

0 commit comments

Comments
 (0)