Skip to content

Commit 285517b

Browse files
committed
feat: improve analysers and add supprress code fixes
1 parent aba3356 commit 285517b

4 files changed

Lines changed: 433 additions & 11 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
extern alias GeneratorEquals;
2+
3+
using GeneratorEquals::Generator.Equals.Analyzers;
4+
using GeneratorEquals::Generator.Equals.Analyzers.CodeFixes;
5+
6+
namespace Generator.Equals.Tests.Analyzers.CodeFixes;
7+
8+
/// <summary>
9+
/// Tests for SuppressWithAttributeCodeFix.
10+
/// </summary>
11+
public sealed class SuppressWithAttributeCodeFixTests : CodeFixTestBase<EquatableAnalyzer, SuppressWithAttributeCodeFix>
12+
{
13+
[Fact]
14+
public async Task GE002_AddsSuppressMessage()
15+
{
16+
const string source = """
17+
using Generator.Equals;
18+
19+
public class Address
20+
{
21+
public string Street { get; set; }
22+
}
23+
24+
[Equatable]
25+
public partial class Person
26+
{
27+
public Address HomeAddress { get; set; }
28+
}
29+
""";
30+
31+
const string fixedSource = """
32+
using Generator.Equals;
33+
34+
public class Address
35+
{
36+
public string Street { get; set; }
37+
}
38+
39+
[Equatable]
40+
public partial class Person
41+
{
42+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Generator.Equals", "GE002:Complex object property type lacks [Equatable]")]
43+
public Address HomeAddress { get; set; }
44+
}
45+
""";
46+
47+
await VerifyCodeFixAsync(source, fixedSource,
48+
Diagnostic(DiagnosticDescriptors.ComplexTypeMissingEquatable)
49+
.WithSpan(11, 12, 11, 19)
50+
.WithArguments("HomeAddress", "Address"));
51+
}
52+
53+
[Fact]
54+
public async Task GE001_AddsSuppressMessage()
55+
{
56+
const string source = """
57+
using System.Collections.Generic;
58+
using Generator.Equals;
59+
60+
[Equatable]
61+
public partial class Sample
62+
{
63+
public List<int> Items { get; set; }
64+
}
65+
""";
66+
67+
const string fixedSource = """
68+
using System.Collections.Generic;
69+
using Generator.Equals;
70+
71+
[Equatable]
72+
public partial class Sample
73+
{
74+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Generator.Equals", "GE001:Collection property missing equality attribute")]
75+
public List<int> Items { get; set; }
76+
}
77+
""";
78+
79+
await VerifyCodeFixAsync(source, fixedSource,
80+
Diagnostic(DiagnosticDescriptors.CollectionMissingAttribute)
81+
.WithSpan(7, 12, 7, 21)
82+
.WithArguments("Items"));
83+
}
84+
85+
[Fact]
86+
public async Task GE003_AddsSuppressMessage()
87+
{
88+
const string source = """
89+
using System.Collections.Generic;
90+
using Generator.Equals;
91+
92+
public class Item { public string Name { get; set; } }
93+
94+
[Equatable]
95+
public partial class Sample
96+
{
97+
[OrderedEquality]
98+
public List<Item> Items { get; set; }
99+
}
100+
""";
101+
102+
const string fixedSource = """
103+
using System.Collections.Generic;
104+
using Generator.Equals;
105+
106+
public class Item { public string Name { get; set; } }
107+
108+
[Equatable]
109+
public partial class Sample
110+
{
111+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Generator.Equals", "GE003:Collection element type lacks [Equatable]")]
112+
[OrderedEquality]
113+
public List<Item> Items { get; set; }
114+
}
115+
""";
116+
117+
await VerifyCodeFixAsync(source, fixedSource,
118+
Diagnostic(DiagnosticDescriptors.CollectionElementMissingEquatable)
119+
.WithSpan(10, 12, 10, 22)
120+
.WithArguments("Items", "Item"));
121+
}
122+
}

Generator.Equals.Tests/Analyzers/GE002ComplexObjectMissingEquatableTests.cs

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,103 @@ public partial class Sample
9797
await VerifyNoDiagnosticAsync(source);
9898
}
9999

100+
[Fact]
101+
public async Task NullableEnumProperty_NoDiagnostic()
102+
{
103+
const string source = """
104+
using Generator.Equals;
105+
106+
public enum SelectionId { None, First, Second }
107+
108+
[Equatable]
109+
public partial class Sample
110+
{
111+
public SelectionId? Id { get; set; }
112+
}
113+
""";
114+
115+
await VerifyNoDiagnosticAsync(source);
116+
}
117+
118+
[Fact]
119+
public async Task RecordStructWithOnlyValueTypes_NoDiagnostic()
120+
{
121+
const string source = """
122+
using Generator.Equals;
123+
124+
namespace System.Runtime.CompilerServices { internal static class IsExternalInit { } }
125+
126+
public readonly record struct Point(int X, int Y);
127+
128+
public readonly record struct Rectangle(Point TopLeft, Point BottomRight);
129+
130+
[Equatable]
131+
public partial class Shape
132+
{
133+
public Rectangle Bounds { get; set; }
134+
}
135+
""";
136+
137+
// Record structs with only value types have deep value equality - no diagnostic needed
138+
await VerifyNoDiagnosticAsync(source);
139+
}
140+
141+
[Fact]
142+
public async Task RecordStructWithCollection_ReportsDiagnostic()
143+
{
144+
const string source = """
145+
using System.Collections.Generic;
146+
using Generator.Equals;
147+
148+
namespace System.Runtime.CompilerServices { internal static class IsExternalInit { } }
149+
150+
public readonly record struct Container(string Name, List<int> Items);
151+
152+
[Equatable]
153+
public partial class Sample
154+
{
155+
public Container Data { get; set; }
156+
}
157+
""";
158+
159+
// Record struct contains a collection which uses reference equality - should warn
160+
// "Container" = 9 chars, starts at col 12, ends at col 21
161+
await VerifyDiagnosticAsync(source,
162+
Diagnostic(DiagnosticDescriptors.ComplexTypeMissingEquatable)
163+
.WithSpan(11, 12, 11, 21)
164+
.WithArguments("Data", "Container"));
165+
}
166+
167+
[Fact]
168+
public async Task NestedRecordStructsWithOnlyValueTypes_NoDiagnostic()
169+
{
170+
const string source = """
171+
using Generator.Equals;
172+
173+
namespace System.Runtime.CompilerServices { internal static class IsExternalInit { } }
174+
175+
public enum Side { Buy, Sell }
176+
177+
public readonly record struct RunnerKey(long Id, string Name);
178+
179+
public readonly record struct MatchedOrder(
180+
string OrderId,
181+
RunnerKey RunnerKey,
182+
Side Side,
183+
decimal Price,
184+
decimal Size);
185+
186+
[Equatable]
187+
public partial class Sample
188+
{
189+
public MatchedOrder Order { get; set; }
190+
}
191+
""";
192+
193+
// Deeply nested record structs with only value types - no diagnostic needed
194+
await VerifyNoDiagnosticAsync(source);
195+
}
196+
100197
[Fact]
101198
public async Task NullablePrimitiveProperty_NoDiagnostic()
102199
{
@@ -199,7 +296,7 @@ public partial class Person
199296
}
200297

201298
[Fact]
202-
public async Task StructProperty_WithoutEquatable_ReportsDiagnostic()
299+
public async Task StructWithOnlyValueTypes_NoDiagnostic()
203300
{
204301
const string source = """
205302
using Generator.Equals;
@@ -217,10 +314,40 @@ public partial class Shape
217314
}
218315
""";
219316

220-
// "Point" = 5 chars, starts at col 12, ends at col 17
317+
// Structs with only value types have value equality via ValueType.Equals
318+
await VerifyNoDiagnosticAsync(source);
319+
}
320+
321+
[Fact]
322+
public async Task StructWithClassField_ReportsDiagnostic()
323+
{
324+
const string source = """
325+
using Generator.Equals;
326+
327+
public class Label
328+
{
329+
public string Text { get; set; }
330+
}
331+
332+
public struct PointWithLabel
333+
{
334+
public int X { get; set; }
335+
public int Y { get; set; }
336+
public Label Tag { get; set; }
337+
}
338+
339+
[Equatable]
340+
public partial class Shape
341+
{
342+
public PointWithLabel Center { get; set; }
343+
}
344+
""";
345+
346+
// Struct contains a class field which uses reference equality - should warn
347+
// "PointWithLabel" = 14 chars, starts at col 12, ends at col 26
221348
await VerifyDiagnosticAsync(source,
222349
Diagnostic(DiagnosticDescriptors.ComplexTypeMissingEquatable)
223-
.WithSpan(12, 12, 12, 17)
224-
.WithArguments("Center", "Point"));
350+
.WithSpan(18, 12, 18, 26)
351+
.WithArguments("Center", "PointWithLabel"));
225352
}
226353
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CodeActions;
8+
using Microsoft.CodeAnalysis.CodeFixes;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
11+
using Microsoft.CodeAnalysis.Editing;
12+
13+
namespace Generator.Equals.Analyzers.CodeFixes;
14+
15+
/// <summary>
16+
/// Code fix that adds [SuppressMessage] attribute to suppress a diagnostic.
17+
/// Available for informational diagnostics where the analyzer might be overly cautious.
18+
/// </summary>
19+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SuppressWithAttributeCodeFix))]
20+
[Shared]
21+
public sealed class SuppressWithAttributeCodeFix : CodeFixProvider
22+
{
23+
public override ImmutableArray<string> FixableDiagnosticIds { get; } =
24+
ImmutableArray.Create(
25+
DiagnosticDescriptors.CollectionMissingAttribute.Id, // GE001
26+
DiagnosticDescriptors.ComplexTypeMissingEquatable.Id, // GE002
27+
DiagnosticDescriptors.CollectionElementMissingEquatable.Id, // GE003
28+
DiagnosticDescriptors.OrphanedEqualityAttribute.Id, // GE004
29+
DiagnosticDescriptors.ManualEqualsImplementation.Id); // GE005
30+
31+
public override FixAllProvider? GetFixAllProvider() => null; // Suppression should be intentional per-case
32+
33+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
34+
{
35+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
36+
if (root == null)
37+
return;
38+
39+
foreach (var diagnostic in context.Diagnostics)
40+
{
41+
var node = root.FindNode(diagnostic.Location.SourceSpan);
42+
43+
// Find the member to add the attribute to
44+
var member = node.FirstAncestorOrSelf<MemberDeclarationSyntax>();
45+
if (member == null)
46+
continue;
47+
48+
var codeAction = CodeAction.Create(
49+
title: $"Suppress {diagnostic.Id} with [SuppressMessage]",
50+
createChangedDocument: ct => AddSuppressMessageAsync(
51+
context.Document, member, diagnostic, ct),
52+
equivalenceKey: $"Suppress_{diagnostic.Id}");
53+
54+
context.RegisterCodeFix(codeAction, diagnostic);
55+
}
56+
}
57+
58+
private static async Task<Document> AddSuppressMessageAsync(
59+
Document document,
60+
MemberDeclarationSyntax member,
61+
Diagnostic diagnostic,
62+
CancellationToken cancellationToken)
63+
{
64+
var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
65+
var generator = editor.Generator;
66+
67+
// Build the [SuppressMessage] attribute
68+
// [SuppressMessage("Generator.Equals", "GE002:Complex object property type lacks [Equatable]")]
69+
var suppressMessageAttribute = SyntaxFactory.Attribute(
70+
SyntaxFactory.ParseName("System.Diagnostics.CodeAnalysis.SuppressMessage"),
71+
SyntaxFactory.AttributeArgumentList(
72+
SyntaxFactory.SeparatedList(new[]
73+
{
74+
SyntaxFactory.AttributeArgument(
75+
SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression,
76+
SyntaxFactory.Literal("Generator.Equals"))),
77+
SyntaxFactory.AttributeArgument(
78+
SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression,
79+
SyntaxFactory.Literal($"{diagnostic.Id}:{diagnostic.Descriptor.Title}")))
80+
})));
81+
82+
// Add the attribute to the member
83+
var attributeList = SyntaxFactory.AttributeList(
84+
SyntaxFactory.SingletonSeparatedList(suppressMessageAttribute));
85+
86+
var newMember = member.WithAttributeLists(
87+
member.AttributeLists.Insert(0, attributeList));
88+
89+
editor.ReplaceNode(member, newMember);
90+
91+
return editor.GetChangedDocument();
92+
}
93+
}

0 commit comments

Comments
 (0)