Skip to content

Commit 491e171

Browse files
committed
chore: more tweaks to the analyser
1 parent 285517b commit 491e171

9 files changed

Lines changed: 393 additions & 32 deletions

Directory.Build.props

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,10 @@
1515
<PackageTags>source generator, equality, equals, equatable</PackageTags>
1616
<NoPackageAnalysis>true</NoPackageAnalysis>
1717
<PackageLicenseExpression>MIT</PackageLicenseExpression>
18+
<PackageReadmeFile>README.md</PackageReadmeFile>
1819
</PropertyGroup>
20+
21+
<ItemGroup>
22+
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" />
23+
</ItemGroup>
1924
</Project>
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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 AddDefaultEqualityCodeFix.
10+
/// </summary>
11+
public sealed class AddDefaultEqualityCodeFixTests : CodeFixTestBase<EquatableAnalyzer, AddDefaultEqualityCodeFix>
12+
{
13+
[Fact]
14+
public async Task GE002_AddsDefaultEquality()
15+
{
16+
const string source = """
17+
using Generator.Equals;
18+
19+
public class ProtobufMessage
20+
{
21+
public string Value { get; set; }
22+
public override bool Equals(object obj) => obj is ProtobufMessage other && Value == other.Value;
23+
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
24+
}
25+
26+
[Equatable]
27+
public partial class Container
28+
{
29+
public ProtobufMessage Message { get; set; }
30+
}
31+
""";
32+
33+
const string fixedSource = """
34+
using Generator.Equals;
35+
36+
public class ProtobufMessage
37+
{
38+
public string Value { get; set; }
39+
public override bool Equals(object obj) => obj is ProtobufMessage other && Value == other.Value;
40+
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
41+
}
42+
43+
[Equatable]
44+
public partial class Container
45+
{
46+
[DefaultEquality]
47+
public ProtobufMessage Message { get; set; }
48+
}
49+
""";
50+
51+
await VerifyCodeFixAsync(source, fixedSource,
52+
Diagnostic(DiagnosticDescriptors.ComplexTypeMissingEquatable)
53+
.WithSpan(13, 12, 13, 27)
54+
.WithArguments("Message", "ProtobufMessage"));
55+
}
56+
57+
[Fact]
58+
public async Task GE003_AddsDefaultEquality()
59+
{
60+
const string source = """
61+
using System.Collections.Generic;
62+
using Generator.Equals;
63+
64+
public class ProtobufMessage
65+
{
66+
public string Value { get; set; }
67+
public override bool Equals(object obj) => obj is ProtobufMessage other && Value == other.Value;
68+
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
69+
}
70+
71+
[Equatable]
72+
public partial class Container
73+
{
74+
[OrderedEquality]
75+
public List<ProtobufMessage> Messages { get; set; }
76+
}
77+
""";
78+
79+
const string fixedSource = """
80+
using System.Collections.Generic;
81+
using Generator.Equals;
82+
83+
public class ProtobufMessage
84+
{
85+
public string Value { get; set; }
86+
public override bool Equals(object obj) => obj is ProtobufMessage other && Value == other.Value;
87+
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
88+
}
89+
90+
[Equatable]
91+
public partial class Container
92+
{
93+
[OrderedEquality]
94+
[DefaultEquality]
95+
public List<ProtobufMessage> Messages { get; set; }
96+
}
97+
""";
98+
99+
// "List<ProtobufMessage>" = 21 chars, starts at col 12, ends at col 33
100+
await VerifyCodeFixAsync(source, fixedSource,
101+
Diagnostic(DiagnosticDescriptors.CollectionElementMissingEquatable)
102+
.WithSpan(15, 12, 15, 33)
103+
.WithArguments("Messages", "ProtobufMessage"));
104+
}
105+
106+
[Fact]
107+
public async Task GE002_AddsDefaultEquality_NullableType()
108+
{
109+
const string source = """
110+
using Generator.Equals;
111+
112+
public class ExternalType
113+
{
114+
public int Id { get; set; }
115+
}
116+
117+
[Equatable]
118+
public partial class Container
119+
{
120+
public ExternalType? Item { get; set; }
121+
}
122+
""";
123+
124+
const string fixedSource = """
125+
using Generator.Equals;
126+
127+
public class ExternalType
128+
{
129+
public int Id { get; set; }
130+
}
131+
132+
[Equatable]
133+
public partial class Container
134+
{
135+
[DefaultEquality]
136+
public ExternalType? Item { get; set; }
137+
}
138+
""";
139+
140+
// "ExternalType?" is the full type name including the nullable marker
141+
await VerifyCodeFixAsync(source, fixedSource,
142+
Diagnostic(DiagnosticDescriptors.ComplexTypeMissingEquatable)
143+
.WithSpan(11, 12, 11, 25)
144+
.WithArguments("Item", "ExternalType?"));
145+
}
146+
}

Generator.Equals.Tests/Analyzers/GE001CollectionMissingAttributeTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,4 +235,47 @@ await VerifyDiagnosticAsync(source,
235235
.WithSpan(7, 12, 7, 28)
236236
.WithArguments("Items"));
237237
}
238+
239+
[Fact]
240+
public async Task CollectionProperty_WithDefaultEquality_ReportsDiagnostic()
241+
{
242+
const string source = """
243+
using System.Collections.Generic;
244+
using Generator.Equals;
245+
246+
[Equatable]
247+
public partial class Sample
248+
{
249+
[DefaultEquality]
250+
public List<int> Items { get; set; }
251+
}
252+
""";
253+
254+
// [DefaultEquality] alone does not suppress GE001 - collection still needs a collection attribute
255+
// List<int> is 9 chars, starts at col 12, ends at col 21
256+
await VerifyDiagnosticAsync(source,
257+
Diagnostic(DiagnosticDescriptors.CollectionMissingAttribute)
258+
.WithSpan(8, 12, 8, 21)
259+
.WithArguments("Items"));
260+
}
261+
262+
[Fact]
263+
public async Task CollectionProperty_WithDefaultAndOrderedEquality_NoDiagnostic()
264+
{
265+
const string source = """
266+
using System.Collections.Generic;
267+
using Generator.Equals;
268+
269+
[Equatable]
270+
public partial class Sample
271+
{
272+
[DefaultEquality]
273+
[OrderedEquality]
274+
public List<int> Items { get; set; }
275+
}
276+
""";
277+
278+
// [DefaultEquality] + [OrderedEquality] satisfies the requirement
279+
await VerifyNoDiagnosticAsync(source);
280+
}
238281
}

Generator.Equals.Tests/Analyzers/GE002ComplexObjectMissingEquatableTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,4 +350,31 @@ await VerifyDiagnosticAsync(source,
350350
.WithSpan(18, 12, 18, 26)
351351
.WithArguments("Center", "PointWithLabel"));
352352
}
353+
354+
[Fact]
355+
public async Task ComplexProperty_WithDefaultEquality_NoDiagnostic()
356+
{
357+
const string source = """
358+
using Generator.Equals;
359+
360+
public class ProtobufMessage
361+
{
362+
public string Value { get; set; }
363+
public override bool Equals(object obj) => obj is ProtobufMessage other && Value == other.Value;
364+
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
365+
}
366+
367+
[Equatable]
368+
public partial class Container
369+
{
370+
public string Name { get; set; }
371+
372+
[DefaultEquality]
373+
public ProtobufMessage Message { get; set; }
374+
}
375+
""";
376+
377+
// [DefaultEquality] explicitly says "use default equality" - no warning needed
378+
await VerifyNoDiagnosticAsync(source);
379+
}
353380
}

Generator.Equals.Tests/Analyzers/GE003CollectionElementMissingEquatableTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,61 @@ await VerifyDiagnosticAsync(source,
228228
.WithSpan(12, 12, 12, 25)
229229
.WithArguments("Addresses", "Address"));
230230
}
231+
232+
[Fact]
233+
public async Task CollectionOfComplexType_WithDefaultEquality_NoGE003Diagnostic()
234+
{
235+
const string source = """
236+
using System.Collections.Generic;
237+
using Generator.Equals;
238+
239+
public class ProtobufMessage
240+
{
241+
public string Value { get; set; }
242+
public override bool Equals(object obj) => obj is ProtobufMessage other && Value == other.Value;
243+
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
244+
}
245+
246+
[Equatable]
247+
public partial class Container
248+
{
249+
[DefaultEquality]
250+
public List<ProtobufMessage> Messages { get; set; }
251+
}
252+
""";
253+
254+
// [DefaultEquality] suppresses GE003 but not GE001 (collection still needs a collection attribute)
255+
// "List<ProtobufMessage>" = 21 chars, starts at col 12, ends at col 33
256+
await VerifyDiagnosticAsync(source,
257+
Diagnostic(DiagnosticDescriptors.CollectionMissingAttribute)
258+
.WithSpan(15, 12, 15, 33)
259+
.WithArguments("Messages"));
260+
}
261+
262+
[Fact]
263+
public async Task CollectionOfComplexType_WithOrderedAndDefaultEquality_NoDiagnostic()
264+
{
265+
const string source = """
266+
using System.Collections.Generic;
267+
using Generator.Equals;
268+
269+
public class ProtobufMessage
270+
{
271+
public string Value { get; set; }
272+
public override bool Equals(object obj) => obj is ProtobufMessage other && Value == other.Value;
273+
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
274+
}
275+
276+
[Equatable]
277+
public partial class Container
278+
{
279+
[DefaultEquality]
280+
[OrderedEquality]
281+
public List<ProtobufMessage> Messages { get; set; }
282+
}
283+
""";
284+
285+
// [DefaultEquality] suppresses GE003, [OrderedEquality] satisfies GE001
286+
await VerifyNoDiagnosticAsync(source);
287+
}
231288
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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.Syntax;
10+
using Microsoft.CodeAnalysis.Editing;
11+
12+
namespace Generator.Equals.Analyzers.CodeFixes;
13+
14+
/// <summary>
15+
/// Code fix that adds [DefaultEquality] attribute to suppress GE002 and GE003.
16+
/// Useful for types that implement their own Equals/GetHashCode (e.g., protobuf-generated types).
17+
/// </summary>
18+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddDefaultEqualityCodeFix))]
19+
[Shared]
20+
public sealed class AddDefaultEqualityCodeFix : CodeFixProvider
21+
{
22+
public override ImmutableArray<string> FixableDiagnosticIds { get; } =
23+
ImmutableArray.Create(
24+
DiagnosticDescriptors.ComplexTypeMissingEquatable.Id, // GE002
25+
DiagnosticDescriptors.CollectionElementMissingEquatable.Id); // GE003
26+
27+
public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
28+
29+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
30+
{
31+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
32+
if (root == null)
33+
return;
34+
35+
foreach (var diagnostic in context.Diagnostics)
36+
{
37+
var node = root.FindNode(diagnostic.Location.SourceSpan);
38+
39+
// Find the property to add the attribute to
40+
var property = node.FirstAncestorOrSelf<PropertyDeclarationSyntax>();
41+
if (property == null)
42+
continue;
43+
44+
var codeAction = CodeAction.Create(
45+
title: "Add [DefaultEquality] (type has its own equality)",
46+
createChangedDocument: ct => AddDefaultEqualityAsync(
47+
context.Document, property, ct),
48+
equivalenceKey: "AddDefaultEquality");
49+
50+
context.RegisterCodeFix(codeAction, diagnostic);
51+
}
52+
}
53+
54+
private static async Task<Document> AddDefaultEqualityAsync(
55+
Document document,
56+
PropertyDeclarationSyntax property,
57+
CancellationToken cancellationToken)
58+
{
59+
var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
60+
var generator = editor.Generator;
61+
62+
// Create the [DefaultEquality] attribute
63+
var attribute = generator.Attribute(
64+
generator.IdentifierName("DefaultEquality"));
65+
66+
// Add the attribute to the property
67+
var newProperty = generator.AddAttributes(property, attribute);
68+
editor.ReplaceNode(property, newProperty);
69+
70+
return editor.GetChangedDocument();
71+
}
72+
}

0 commit comments

Comments
 (0)