From d48db3e94ec14328813e9a2134a809c5cd136fcb Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Thu, 5 Sep 2024 10:45:37 -0500 Subject: [PATCH] add diagnostics --- .github/dependabot.yml | 2 + Equatable.Generator.sln | 1 + .../DiagnosticDescriptors.cs | 43 +++++ .../EquatableGenerator.cs | 132 ++++++++++++++- .../EquatableGeneratorTest.cs | 155 +++++++++++++++++- 5 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 src/Equatable.SourceGenerator/DiagnosticDescriptors.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fa8c583..242a072 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,8 @@ updates: time: "02:00" timezone: "America/Chicago" open-pull-requests-limit: 10 + ignore: + - dependency-name: "Microsoft.CodeAnalysis.CSharp" groups: Azure: patterns: diff --git a/Equatable.Generator.sln b/Equatable.Generator.sln index d3bb8ba..659df0c 100644 --- a/Equatable.Generator.sln +++ b/Equatable.Generator.sln @@ -9,6 +9,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Equatable.SourceGenerator", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{9ABD9B14-9E53-463A-96B0-FA7F503BA826}" ProjectSection(SolutionItems) = preProject + .github\dependabot.yml = .github\dependabot.yml src\Directory.Build.props = src\Directory.Build.props .github\workflows\dotnet.yml = .github\workflows\dotnet.yml README.md = README.md diff --git a/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs new file mode 100644 index 0000000..6f7101a --- /dev/null +++ b/src/Equatable.SourceGenerator/DiagnosticDescriptors.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis; + +namespace Equatable.SourceGenerator; + +internal static class DiagnosticDescriptors +{ + public static DiagnosticDescriptor InvalidStringEqualityAttributeUsage => new( + id: "EQ0010", + title: "Invalid String Equality Attribute Usage", + messageFormat: "Invalid String equality attribute usage for property {0}. Property return type is not a string", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static DiagnosticDescriptor InvalidDictionaryEqualityAttributeUsage => new( + id: "EQ0011", + title: "Invalid Dictionary Equality Attribute Usage", + messageFormat: "Invalid Dictionary equality attribute usage for property {0}. Property return type does not implement IDictionary", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static DiagnosticDescriptor InvalidHashSetEqualityAttributeUsage => new( + id: "EQ0012", + title: "Invalid HashSet Equality Attribute Usage", + messageFormat: "Invalid HashSet equality attribute usage for property {0}. Property return type does not implement IEnumerable", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static DiagnosticDescriptor InvalidSequenceEqualityAttributeUsage => new( + id: "EQ0013", + title: "Invalid Sequence Equality Attribute Usage", + messageFormat: "Invalid Sequence equality attribute usage for property {0}. Property return type does not implement IEnumerable", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + +} diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs index e8fa092..a45eac7 100644 --- a/src/Equatable.SourceGenerator/EquatableGenerator.cs +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -72,6 +72,8 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken if (context.TargetSymbol is not INamedTypeSymbol targetSymbol) return null; + var diagnostics = new List(); + var fullyQualified = targetSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var classNamespace = targetSymbol.ContainingNamespace.ToDisplayString(); var className = targetSymbol.Name; @@ -86,7 +88,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken var propertySymbols = GetProperties(targetSymbol, baseHashCode == null && baseEquatable == null); var propertyArray = propertySymbols - .Select(CreateProperty) + .Select(symbol => CreateProperty(diagnostics, symbol)) .ToArray() ?? []; // the seed value of the hash code method @@ -116,7 +118,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken SeedHash: seedHash ); - return new EquatableContext(entity, null); + return new EquatableContext(entity, diagnostics.ToArray()); } @@ -148,7 +150,7 @@ private static IEnumerable GetProperties(INamedTypeSymbol targe return properties.Values; } - private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) + private static EquatableProperty CreateProperty(List diagnostics, IPropertySymbol propertySymbol) { var format = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); var propertyType = propertySymbol.Type.ToDisplayString(format); @@ -172,6 +174,17 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) if (!comparerType.HasValue) continue; + var diagnostic = ValidateComparer(propertySymbol, comparerType); + if (diagnostic != null) + { + diagnostics.Add(diagnostic); + + return new EquatableProperty( + propertyName, + propertyType, + ComparerTypes.Default); + } + return new EquatableProperty( propertyName, propertyType, @@ -186,6 +199,63 @@ private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) ComparerTypes.Default); } + private static Diagnostic? ValidateComparer(IPropertySymbol propertySymbol, ComparerTypes? comparerType) + { + // don't need to validate these types + if (comparerType is null or ComparerTypes.Default or ComparerTypes.Reference or ComparerTypes.Custom) + return null; + + if (comparerType == ComparerTypes.String) + { + if (IsString(propertySymbol.Type)) + return null; + + return Diagnostic.Create( + DiagnosticDescriptors.InvalidStringEqualityAttributeUsage, + propertySymbol.Locations.FirstOrDefault(), + propertySymbol.Name + ); + } + + if (comparerType == ComparerTypes.Dictionary) + { + if (propertySymbol.Type.AllInterfaces.Any(IsDictionary)) + return null; + + return Diagnostic.Create( + DiagnosticDescriptors.InvalidDictionaryEqualityAttributeUsage, + propertySymbol.Locations.FirstOrDefault(), + propertySymbol.Name + ); + } + + if (comparerType == ComparerTypes.HashSet) + { + if (propertySymbol.Type.AllInterfaces.Any(IsEnumerable)) + return null; + + return Diagnostic.Create( + DiagnosticDescriptors.InvalidHashSetEqualityAttributeUsage, + propertySymbol.Locations.FirstOrDefault(), + propertySymbol.Name + ); + } + + if (comparerType == ComparerTypes.Sequence) + { + if (propertySymbol.Type.AllInterfaces.Any(IsEnumerable)) + return null; + + return Diagnostic.Create( + DiagnosticDescriptors.InvalidSequenceEqualityAttributeUsage, + propertySymbol.Locations.FirstOrDefault(), + propertySymbol.Name + ); + } + + return null; + } + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetComparer(AttributeData? attribute) { @@ -293,6 +363,62 @@ private static bool IsValueType(INamedTypeSymbol targetSymbol) }; } + private static bool IsEnumerable(INamedTypeSymbol targetSymbol) + { + return targetSymbol is + { + Name: "IEnumerable", + IsGenericType: true, + TypeArguments.Length: 1, + TypeParameters.Length: 1, + ContainingNamespace: + { + Name: "Generic", + ContainingNamespace: + { + Name: "Collections", + ContainingNamespace: + { + Name: "System" + } + } + } + }; + } + + private static bool IsDictionary(INamedTypeSymbol targetSymbol) + { + return targetSymbol is + { + Name: "IDictionary", + IsGenericType: true, + TypeArguments.Length: 2, + TypeParameters.Length: 2, + ContainingNamespace: + { + Name: "Generic", + ContainingNamespace: + { + Name: "Collections", + ContainingNamespace: + { + Name: "System" + } + } + } + }; + } + + private static bool IsString(ITypeSymbol targetSymbol) + { + return targetSymbol is + { + Name: nameof(String), + ContainingNamespace.Name: "System" + }; + } + + private static EquatableArray GetContainingTypes(INamedTypeSymbol targetSymbol) { if (targetSymbol.ContainingType is null) diff --git a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs index 5f1e138..a67ee9d 100644 --- a/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs +++ b/test/Equatable.Generator.Tests/EquatableGeneratorTest.cs @@ -26,6 +26,7 @@ public partial class UserImport [StringEquality(StringComparison.OrdinalIgnoreCase)] public string EmailAddress { get; set; } = null!; + [JsonPropertyName(""name"")] public string? DisplayName { get; set; } public string? FirstName { get; set; } @@ -36,6 +37,7 @@ public partial class UserImport public DateTimeOffset? LastLogin { get; set; } + [JsonIgnore] [IgnoreEquality] public string FullName => $""{FirstName} {LastName}""; @@ -387,6 +389,158 @@ public partial class Animal .ScrubLinesContaining("GeneratedCodeAttribute"); } + + [Fact] + public Task GenerateInvalidStringEquality() + { + var source = @" +using System; +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + public string EmailAddress { get; set; } = null!; + + [JsonPropertyName(""name"")] + public string? DisplayName { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public DateTimeOffset? LockoutEnd { get; set; } + + public DateTimeOffset? LastLogin { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + diagnostics.Should().NotBeEmpty(); + diagnostics[0].Id.Should().Be("EQ0010"); + + return Task.CompletedTask; + } + + [Fact] + public Task GenerateInvalidSequenceEquality() + { + var source = @" +using System; +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string EmailAddress { get; set; } = null!; + + [JsonPropertyName(""name"")] + public string? DisplayName { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public DateTimeOffset? LockoutEnd { get; set; } + + [SequenceEquality] + public DateTimeOffset? LastLogin { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + diagnostics.Should().NotBeEmpty(); + diagnostics[0].Id.Should().Be("EQ0013"); + + return Task.CompletedTask; + } + + [Fact] + public Task GenerateInvalidHashSetEquality() + { + var source = @" +using System; +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string EmailAddress { get; set; } = null!; + + [JsonPropertyName(""name"")] + public string? DisplayName { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public DateTimeOffset? LockoutEnd { get; set; } + + [HashSetEquality] + public DateTimeOffset? LastLogin { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + diagnostics.Should().NotBeEmpty(); + diagnostics[0].Id.Should().Be("EQ0012"); + + return Task.CompletedTask; + } + + [Fact] + public Task GenerateInvalidDictionaryEquality() + { + var source = @" +using System; +using System.Collections.Generic; +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string EmailAddress { get; set; } = null!; + + [JsonPropertyName(""name"")] + public string? DisplayName { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public DateTimeOffset? LockoutEnd { get; set; } + + [DictionaryEquality] + public DateTimeOffset? LastLogin { get; set; } +} +"; + + var (diagnostics, output) = GetGeneratedOutput(source); + + diagnostics.Should().NotBeEmpty(); + diagnostics[0].Id.Should().Be("EQ0011"); + + return Task.CompletedTask; + } + private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput(string source) where T : IIncrementalGenerator, new() { @@ -416,5 +570,4 @@ private static (ImmutableArray Diagnostics, string Output) GetGenera return (diagnostics, trees.Count != originalTreeCount ? trees[^1].ToString() : string.Empty); } - }