Skip to content

Commit d3fa4a8

Browse files
Copilotandrewlock
andauthored
Add analyzer warning for duplicate enum case labels (NEEG003) (#162)
* Initial plan * Initial plan and exploration for duplicate case label analyzer Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Add DuplicateEnumValueAnalyzer with comprehensive tests Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Add final test case and complete DuplicateEnumValueAnalyzer implementation Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Revert tfm * Tweak and update * Improve analyzer and update --------- 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 360a0d0 commit d3fa4a8

File tree

2 files changed

+311
-0
lines changed

2 files changed

+311
-0
lines changed
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 System.Linq;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Diagnostics;
7+
8+
namespace NetEscapades.EnumGenerators.Diagnostics;
9+
10+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
11+
public class DuplicateEnumValueAnalyzer : DiagnosticAnalyzer
12+
{
13+
public const string DiagnosticId = "NEEG003";
14+
public static readonly DiagnosticDescriptor Rule = new(
15+
#pragma warning disable RS2008 // Enable Analyzer Release Tracking
16+
id: DiagnosticId,
17+
#pragma warning restore RS2008
18+
title: "Enum has duplicate values and will give inconsistent values for ToStringFast()",
19+
messageFormat: "The enum member '{0}' has the same value as a previous member so will return the '{1}' value for ToStringFast()",
20+
category: "Usage",
21+
defaultSeverity: DiagnosticSeverity.Info,
22+
isEnabledByDefault: true);
23+
24+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
25+
=> ImmutableArray.Create(Rule);
26+
27+
public override void Initialize(AnalysisContext context)
28+
{
29+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
30+
context.EnableConcurrentExecution();
31+
context.RegisterSyntaxNodeAction(AnalyzeEnumDeclaration, SyntaxKind.EnumDeclaration);
32+
}
33+
34+
private static void AnalyzeEnumDeclaration(SyntaxNodeAnalysisContext context)
35+
{
36+
var enumDeclaration = (EnumDeclarationSyntax)context.Node;
37+
38+
// Check if enum has [EnumExtensions] attribute
39+
bool hasEnumExtensionsAttribute = false;
40+
foreach (var attributeList in enumDeclaration.AttributeLists)
41+
{
42+
foreach (var attribute in attributeList.Attributes)
43+
{
44+
// Check attribute name syntactically first
45+
var attributeName = attribute.Name.ToString();
46+
if (attributeName == "EnumExtensions" || attributeName == "EnumExtensionsAttribute")
47+
{
48+
// Verify with semantic model for precision
49+
var symbolInfo = context.SemanticModel.GetSymbolInfo(attribute);
50+
if (symbolInfo.Symbol is IMethodSymbol method &&
51+
method.ContainingType.ToDisplayString() == Attributes.EnumExtensionsAttribute)
52+
{
53+
hasEnumExtensionsAttribute = true;
54+
break;
55+
}
56+
}
57+
}
58+
59+
if (hasEnumExtensionsAttribute)
60+
{
61+
break;
62+
}
63+
}
64+
65+
if (!hasEnumExtensionsAttribute)
66+
{
67+
return;
68+
}
69+
70+
// Get the enum symbol
71+
var enumSymbol = context.SemanticModel.GetDeclaredSymbol(enumDeclaration);
72+
if (enumSymbol is null)
73+
{
74+
return;
75+
}
76+
77+
// Track which constant values we've seen and the first name
78+
var seenValues = new Dictionary<object, string>();
79+
80+
// Analyze each enum member
81+
foreach (var member in enumSymbol.GetMembers().OfType<IFieldSymbol>())
82+
{
83+
if (!member.IsConst || member.ConstantValue is null)
84+
{
85+
continue;
86+
}
87+
88+
var constantValue = member.ConstantValue;
89+
var memberName = member.Name;
90+
91+
// If we've already seen this value, this member will be excluded
92+
if (seenValues.TryGetValue(constantValue, out var previousName))
93+
{
94+
// Find the syntax location for this member
95+
var memberLocation = member.Locations.FirstOrDefault();
96+
if (memberLocation is not null)
97+
{
98+
var diagnostic = Diagnostic.Create(
99+
Rule,
100+
memberLocation,
101+
memberName,
102+
previousName);
103+
104+
context.ReportDiagnostic(diagnostic);
105+
}
106+
}
107+
else
108+
{
109+
seenValues[member.ConstantValue] = memberName;
110+
}
111+
}
112+
}
113+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Threading.Tasks;
3+
using NetEscapades.EnumGenerators.Diagnostics;
4+
using Xunit;
5+
using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
6+
NetEscapades.EnumGenerators.Diagnostics.DuplicateEnumValueAnalyzer,
7+
Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
8+
9+
namespace NetEscapades.EnumGenerators.Tests;
10+
11+
public class DuplicateEnumValueAnalyzerTests
12+
{
13+
private const string DiagnosticId = DuplicateEnumValueAnalyzer.DiagnosticId;
14+
15+
[Fact]
16+
public async Task EmptySourceShouldNotHaveDiagnostics()
17+
{
18+
var test = string.Empty;
19+
await Verifier.VerifyAnalyzerAsync(test);
20+
}
21+
22+
[Fact]
23+
public async Task EnumWithoutAttributeShouldNotHaveDiagnostics()
24+
{
25+
var test = GetTestCode(
26+
/* lang=c# */
27+
"""
28+
public enum TestEnum
29+
{
30+
First = 0,
31+
Second = 0,
32+
}
33+
""");
34+
await Verifier.VerifyAnalyzerAsync(test);
35+
}
36+
37+
[Fact]
38+
public async Task EnumWithoutDuplicatesShouldNotHaveDiagnostics()
39+
{
40+
var test = GetTestCode(
41+
/* lang=c# */
42+
"""
43+
[EnumExtensions]
44+
public enum TestEnum
45+
{
46+
First = 0,
47+
Second = 1,
48+
Third = 2,
49+
}
50+
""");
51+
await Verifier.VerifyAnalyzerAsync(test);
52+
}
53+
54+
[Fact]
55+
public async Task EnumWithDuplicateValuesShouldHaveDiagnosticsForSecondOccurrence()
56+
{
57+
var test = GetTestCode(
58+
/* lang=c# */
59+
"""
60+
[EnumExtensions]
61+
public enum TestEnum
62+
{
63+
First = 0,
64+
{|NEEG003:Second|} = 0,
65+
}
66+
""");
67+
await Verifier.VerifyAnalyzerAsync(test);
68+
}
69+
70+
[Fact]
71+
public async Task EnumWithMultipleDuplicateValuesShouldHaveDiagnosticsForAllButFirst()
72+
{
73+
var test = GetTestCode(
74+
/* lang=c# */
75+
"""
76+
[EnumExtensions]
77+
public enum TestEnum
78+
{
79+
First = 0,
80+
{|NEEG003:Second|} = 0,
81+
{|NEEG003:Third|} = 0,
82+
Fourth = 1,
83+
{|NEEG003:Fifth|} = 1,
84+
}
85+
""");
86+
await Verifier.VerifyAnalyzerAsync(test);
87+
}
88+
89+
[Fact]
90+
public async Task EnumWithImplicitValuesShouldDetectDuplicates()
91+
{
92+
var test = GetTestCode(
93+
/* lang=c# */
94+
"""
95+
[EnumExtensions]
96+
public enum TestEnum
97+
{
98+
First, // 0
99+
{|NEEG003:Second|} = 0, // duplicate of First
100+
{|NEEG003:Third|} = 0, // duplicate of First and Second
101+
}
102+
""");
103+
await Verifier.VerifyAnalyzerAsync(test);
104+
}
105+
106+
[Fact]
107+
public async Task EnumWithDifferentTypesButSameValuesShouldDetectDuplicates()
108+
{
109+
var test = GetTestCode(
110+
/* lang=c# */
111+
"""
112+
[EnumExtensions]
113+
public enum TestEnum : byte
114+
{
115+
First = 0,
116+
{|NEEG003:Second|} = 0,
117+
}
118+
""");
119+
await Verifier.VerifyAnalyzerAsync(test);
120+
}
121+
122+
[Fact]
123+
public async Task MultipleEnumsWithDuplicatesShouldTrackSeparately()
124+
{
125+
var test = GetTestCode(
126+
/* lang=c# */
127+
"""
128+
[EnumExtensions]
129+
public enum FirstEnum
130+
{
131+
A = 0,
132+
{|NEEG003:B|} = 0,
133+
}
134+
135+
[EnumExtensions]
136+
public enum SecondEnum
137+
{
138+
X = 0,
139+
{|NEEG003:Y|} = 0,
140+
}
141+
""");
142+
await Verifier.VerifyAnalyzerAsync(test);
143+
}
144+
145+
[Fact]
146+
public async Task ComplexEnumThatReferencesOthers()
147+
{
148+
var test = GetTestCode(
149+
/* lang=c# */
150+
"""
151+
[EnumExtensions]
152+
public enum ComplexEnum
153+
{
154+
Zero = 0,
155+
One = 1,
156+
{|NEEG003:AlsoOne|} = One,
157+
}
158+
""");
159+
await Verifier.VerifyAnalyzerAsync(test);
160+
}
161+
162+
[Fact]
163+
public async Task ComplexEnumThatReferencesCombinationOfOthers()
164+
{
165+
var test = GetTestCode(
166+
/* lang=c# */
167+
"""
168+
[EnumExtensions]
169+
public enum ComplexEnum
170+
{
171+
Zero = 0,
172+
One = 1,
173+
Two = 2,
174+
{|NEEG003:ZeroAndOne|} = Zero | One,
175+
OneAndTwo = One | Two,
176+
}
177+
""");
178+
await Verifier.VerifyAnalyzerAsync(test);
179+
}
180+
181+
private static string GetTestCode(string testCode) => $$"""
182+
using System;
183+
using System.Collections.Generic;
184+
using System.Linq;
185+
using System.Text;
186+
using System.Threading;
187+
using System.Threading.Tasks;
188+
using System.Diagnostics;
189+
using NetEscapades.EnumGenerators;
190+
191+
namespace ConsoleApplication1
192+
{
193+
{{testCode}}
194+
}
195+
196+
{{TestHelpers.LoadEmbeddedAttribute()}}
197+
""";
198+
}

0 commit comments

Comments
 (0)