diff --git a/src/NetEscapades.EnumGenerators.Generators/Diagnostics/DefinitionAnalyzers/IncorrectMetadataAttributeAnalyzer.cs b/src/NetEscapades.EnumGenerators.Generators/Diagnostics/DefinitionAnalyzers/IncorrectMetadataAttributeAnalyzer.cs new file mode 100644 index 0000000..a2ca1c0 --- /dev/null +++ b/src/NetEscapades.EnumGenerators.Generators/Diagnostics/DefinitionAnalyzers/IncorrectMetadataAttributeAnalyzer.cs @@ -0,0 +1,227 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace NetEscapades.EnumGenerators.Diagnostics.DefinitionAnalyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class IncorrectMetadataAttributeAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "NEEG013"; + public static readonly DiagnosticDescriptor Rule = new( +#pragma warning disable RS2008 // Enable Analyzer Release Tracking + id: DiagnosticId, +#pragma warning restore RS2008 + title: "Metadata attribute will be ignored", + messageFormat: "The '{0}' attribute on enum member '{1}' will be ignored because the enum is configured to use '{2}'", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeEnumDeclaration, SyntaxKind.EnumDeclaration); + } + + private static MetadataSource GetDefaultMetadataSource(AnalyzerOptions options) + { + const MetadataSource defaultValue = MetadataSource.EnumMemberAttribute; + + if (options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue( + $"build_property.{Constants.MetadataSourcePropertyName}", + out var source)) + { + return source switch + { + nameof(MetadataSource.None) => MetadataSource.None, + nameof(MetadataSource.DisplayAttribute) => MetadataSource.DisplayAttribute, + nameof(MetadataSource.DescriptionAttribute) => MetadataSource.DescriptionAttribute, + nameof(MetadataSource.EnumMemberAttribute) => MetadataSource.EnumMemberAttribute, + _ => defaultValue, + }; + } + + return defaultValue; + } + + private static void AnalyzeEnumDeclaration(SyntaxNodeAnalysisContext context) + { + // Get the default metadata source from MSBuild properties + var defaultMetadataSource = GetDefaultMetadataSource(context.Options); + var enumDeclaration = (EnumDeclarationSyntax)context.Node; + + // Check if enum has [EnumExtensions] attribute + AttributeSyntax? enumExtensionsAttribute = null; + MetadataSource? explicitMetadataSource = null; + + foreach (var attributeList in enumDeclaration.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + // Check attribute name syntactically first + var attributeName = attribute.Name.ToString(); + if (attributeName == "EnumExtensions" || attributeName == "EnumExtensionsAttribute") + { + // Verify with semantic model for precision + var symbolInfo = context.SemanticModel.GetSymbolInfo(attribute); + if (symbolInfo.Symbol is IMethodSymbol method && + method.ContainingType.ToDisplayString() == TypeNames.EnumExtensionsAttribute) + { + enumExtensionsAttribute = attribute; + + // Check if MetadataSource is explicitly set + if (attribute.ArgumentList is not null) + { + foreach (var arg in attribute.ArgumentList.Arguments) + { + if (arg.NameEquals?.Name.Identifier.Text == "MetadataSource") + { + // Try to get the metadata source value + var attrData = context.SemanticModel.GetSymbolInfo(attribute).Symbol?.ContainingType; + foreach (var attrDataItem in context.SemanticModel.GetDeclaredSymbol(enumDeclaration)?.GetAttributes() ?? Enumerable.Empty()) + { + if (attrDataItem.AttributeClass?.ToDisplayString() == TypeNames.EnumExtensionsAttribute) + { + foreach (var namedArg in attrDataItem.NamedArguments) + { + if (namedArg.Key == "MetadataSource" && namedArg.Value.Value is int metadataSourceValue) + { + explicitMetadataSource = (MetadataSource)metadataSourceValue; + break; + } + } + } + } + break; + } + } + } + break; + } + } + } + + if (enumExtensionsAttribute is not null) + { + break; + } + } + + if (enumExtensionsAttribute is null) + { + return; + } + + // Determine the effective metadata source + var effectiveMetadataSource = explicitMetadataSource ?? defaultMetadataSource; + + // If MetadataSource is None, no attributes will be used, so no need to warn + if (effectiveMetadataSource == MetadataSource.None) + { + return; + } + + // Get the enum symbol + var enumSymbol = context.SemanticModel.GetDeclaredSymbol(enumDeclaration); + if (enumSymbol is null) + { + return; + } + + // Track which metadata attributes are found + bool hasCorrectAttribute = false; + var incorrectAttributes = new System.Collections.Generic.List<(Location Location, string AttributeName, string MemberName)>(); + + // Analyze each enum member + foreach (var member in enumSymbol.GetMembers().OfType()) + { + if (!member.IsConst) + { + continue; + } + + foreach (var attribute in member.GetAttributes()) + { + var attributeType = attribute.AttributeClass?.ToDisplayString(); + + if (attributeType == TypeNames.DisplayAttribute) + { + if (effectiveMetadataSource == MetadataSource.DisplayAttribute) + { + hasCorrectAttribute = true; + } + else + { + var location = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation(); + if (location is not null) + { + incorrectAttributes.Add((location, "Display", member.Name)); + } + } + } + else if (attributeType == TypeNames.DescriptionAttribute) + { + if (effectiveMetadataSource == MetadataSource.DescriptionAttribute) + { + hasCorrectAttribute = true; + } + else + { + var location = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation(); + if (location is not null) + { + incorrectAttributes.Add((location, "Description", member.Name)); + } + } + } + else if (attributeType == TypeNames.EnumMemberAttribute) + { + if (effectiveMetadataSource == MetadataSource.EnumMemberAttribute) + { + hasCorrectAttribute = true; + } + else + { + var location = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation(); + if (location is not null) + { + incorrectAttributes.Add((location, "EnumMember", member.Name)); + } + } + } + } + } + + // Only report diagnostics if we found incorrect attributes and no correct attributes + if (incorrectAttributes.Count > 0 && !hasCorrectAttribute) + { + var effectiveSourceName = effectiveMetadataSource switch + { + MetadataSource.DisplayAttribute => "DisplayAttribute", + MetadataSource.DescriptionAttribute => "DescriptionAttribute", + MetadataSource.EnumMemberAttribute => "EnumMemberAttribute", + _ => "None" + }; + + foreach (var (location, attributeName, memberName) in incorrectAttributes) + { + var diagnostic = Diagnostic.Create( + Rule, + location, + attributeName, + memberName, + effectiveSourceName); + + context.ReportDiagnostic(diagnostic); + } + } + } +} diff --git a/src/NetEscapades.EnumGenerators.Generators/Diagnostics/DefinitionAnalyzers/IncorrectMetadataAttributeCodeFixProvider.cs b/src/NetEscapades.EnumGenerators.Generators/Diagnostics/DefinitionAnalyzers/IncorrectMetadataAttributeCodeFixProvider.cs new file mode 100644 index 0000000..fe0cc7a --- /dev/null +++ b/src/NetEscapades.EnumGenerators.Generators/Diagnostics/DefinitionAnalyzers/IncorrectMetadataAttributeCodeFixProvider.cs @@ -0,0 +1,224 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace NetEscapades.EnumGenerators.Diagnostics.DefinitionAnalyzers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(IncorrectMetadataAttributeCodeFixProvider)), Shared] +public class IncorrectMetadataAttributeCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds + => ImmutableArray.Create(IncorrectMetadataAttributeAnalyzer.DiagnosticId); + + public sealed override FixAllProvider GetFixAllProvider() + => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var diagnosticSpan = diagnostic.Location.SourceSpan; + + // Find the attribute that triggered the diagnostic + var attributeSyntax = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); + if (attributeSyntax is null) + { + return; + } + + // Find the enum declaration + var enumDeclaration = attributeSyntax.AncestorsAndSelf().OfType().FirstOrDefault(); + if (enumDeclaration is null) + { + return; + } + + // Determine what metadata source should be used based on the attributes present + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + var enumSymbol = semanticModel.GetDeclaredSymbol(enumDeclaration, context.CancellationToken); + if (enumSymbol is null) + { + return; + } + + // Determine which metadata source to suggest based on the attributes present + var suggestedSource = DetermineMetadataSource(enumSymbol); + if (suggestedSource is null) + { + return; + } + + var metadataSourceName = suggestedSource.Value switch + { + MetadataSource.DisplayAttribute => "DisplayAttribute", + MetadataSource.DescriptionAttribute => "DescriptionAttribute", + MetadataSource.EnumMemberAttribute => "EnumMemberAttribute", + _ => null + }; + + if (metadataSourceName is null) + { + return; + } + + // Register a code action that will update the EnumExtensions attribute + context.RegisterCodeFix( + CodeAction.Create( + title: $"Set MetadataSource to {metadataSourceName}", + createChangedDocument: c => UpdateEnumExtensionsAttribute(context.Document, enumDeclaration, suggestedSource.Value, c), + equivalenceKey: nameof(IncorrectMetadataAttributeCodeFixProvider)), + diagnostic); + } + + private static MetadataSource? DetermineMetadataSource(INamedTypeSymbol enumSymbol) + { + bool hasDisplay = false; + bool hasDescription = false; + bool hasEnumMember = false; + + foreach (var member in enumSymbol.GetMembers().OfType()) + { + if (!member.IsConst) + { + continue; + } + + foreach (var attribute in member.GetAttributes()) + { + var attributeType = attribute.AttributeClass?.ToDisplayString(); + + if (attributeType == TypeNames.DisplayAttribute) + { + hasDisplay = true; + } + else if (attributeType == TypeNames.DescriptionAttribute) + { + hasDescription = true; + } + else if (attributeType == TypeNames.EnumMemberAttribute) + { + hasEnumMember = true; + } + } + } + + // Prioritize based on what's most commonly used + if (hasDisplay) + { + return MetadataSource.DisplayAttribute; + } + if (hasDescription) + { + return MetadataSource.DescriptionAttribute; + } + if (hasEnumMember) + { + return MetadataSource.EnumMemberAttribute; + } + + return null; + } + + private static async Task UpdateEnumExtensionsAttribute( + Document document, + EnumDeclarationSyntax enumDeclaration, + MetadataSource metadataSource, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + // Find the EnumExtensions attribute + AttributeSyntax? enumExtensionsAttribute = null; + foreach (var attributeList in enumDeclaration.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var attributeName = attribute.Name.ToString(); + if (attributeName == "EnumExtensions" || attributeName == "EnumExtensionsAttribute") + { + enumExtensionsAttribute = attribute; + break; + } + } + + if (enumExtensionsAttribute is not null) + { + break; + } + } + + if (enumExtensionsAttribute is null) + { + return document; + } + + // Create the metadata source expression + var metadataSourceExpression = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("MetadataSource"), + SyntaxFactory.IdentifierName(metadataSource.ToString())); + + // Create the argument for MetadataSource + var metadataSourceArgument = SyntaxFactory.AttributeArgument( + SyntaxFactory.NameEquals(SyntaxFactory.IdentifierName("MetadataSource")), + null, + metadataSourceExpression); + + // Check if the attribute already has an argument list + AttributeSyntax newAttribute; + if (enumExtensionsAttribute.ArgumentList is not null) + { + // Check if MetadataSource is already specified + var existingMetadataSourceArg = enumExtensionsAttribute.ArgumentList.Arguments + .FirstOrDefault(arg => arg.NameEquals?.Name.Identifier.Text == "MetadataSource"); + + if (existingMetadataSourceArg is not null) + { + // Replace the existing argument + var newArguments = enumExtensionsAttribute.ArgumentList.Arguments + .Replace(existingMetadataSourceArg, metadataSourceArgument); + newAttribute = enumExtensionsAttribute.WithArgumentList( + enumExtensionsAttribute.ArgumentList.WithArguments(newArguments)); + } + else + { + // Add the new argument + var newArguments = enumExtensionsAttribute.ArgumentList.Arguments.Add(metadataSourceArgument); + newAttribute = enumExtensionsAttribute.WithArgumentList( + enumExtensionsAttribute.ArgumentList.WithArguments(newArguments)); + } + } + else + { + // Create a new argument list + newAttribute = enumExtensionsAttribute.WithArgumentList( + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList(metadataSourceArgument))); + } + + // Replace the old attribute with the new one + var newRoot = root.ReplaceNode(enumExtensionsAttribute, newAttribute); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/tests/NetEscapades.EnumGenerators.Tests/IncorrectMetadataAttributeAnalyzerTests.cs b/tests/NetEscapades.EnumGenerators.Tests/IncorrectMetadataAttributeAnalyzerTests.cs new file mode 100644 index 0000000..ef6abcf --- /dev/null +++ b/tests/NetEscapades.EnumGenerators.Tests/IncorrectMetadataAttributeAnalyzerTests.cs @@ -0,0 +1,323 @@ +using System.Threading.Tasks; +using NetEscapades.EnumGenerators.Diagnostics.DefinitionAnalyzers; +using Xunit; +using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier< + NetEscapades.EnumGenerators.Diagnostics.DefinitionAnalyzers.IncorrectMetadataAttributeAnalyzer, + Microsoft.CodeAnalysis.Testing.DefaultVerifier>; + +namespace NetEscapades.EnumGenerators.Tests; + +public class IncorrectMetadataAttributeAnalyzerTests +{ + private const string DiagnosticId = IncorrectMetadataAttributeAnalyzer.DiagnosticId; + + [Fact] + public async Task EmptySourceShouldNotHaveDiagnostics() + { + var test = string.Empty; + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithoutAttributeShouldNotHaveDiagnostics() + { + var test = GetTestCode( + """ + public enum TestEnum + { + [Display(Name = "First")] + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithCorrectDefaultAttributeShouldNotHaveDiagnostics() + { + // Default is EnumMemberAttribute, so using EnumMember should not trigger diagnostic + var test = GetTestCode( + """ + [EnumExtensions] + public enum TestEnum + { + [EnumMember(Value = "first")] + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithExplicitMetadataSourceMatchingAttributesShouldNotHaveDiagnostics() + { + var test = GetTestCode( + """ + [EnumExtensions(MetadataSource = MetadataSource.DisplayAttribute)] + public enum TestEnum + { + [Display(Name = "First")] + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithNoMetadataAttributesShouldNotHaveDiagnostics() + { + var test = GetTestCode( + """ + [EnumExtensions] + public enum TestEnum + { + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithMetadataSourceNoneShouldNotHaveDiagnostics() + { + // When MetadataSource is None, attributes are ignored so no warning needed + var test = GetTestCode( + """ + [EnumExtensions(MetadataSource = MetadataSource.None)] + public enum TestEnum + { + [Display(Name = "First")] + First, + [Description("Second")] + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithWrongMetadataAttributeShouldHaveDiagnostic() + { + // Default is EnumMemberAttribute, but we're using Display + var test = GetTestCode( + """ + [EnumExtensions] + public enum TestEnum + { + [{|NEEG013:Display(Name = "First")|}] + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithMultipleWrongMetadataAttributesShouldHaveDiagnostics() + { + var test = GetTestCode( + """ + [EnumExtensions] + public enum TestEnum + { + [{|NEEG013:Display(Name = "First")|}] + First, + [{|NEEG013:Display(Name = "Second")|}] + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithMixedAttributesShouldNotHaveDiagnostic() + { + // Has both correct (EnumMember) and incorrect (Display) attributes + // Should not report diagnostic because at least one correct attribute exists + var test = GetTestCode( + """ + [EnumExtensions] + public enum TestEnum + { + [EnumMember(Value = "first")] + First, + [Display(Name = "Second")] + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithExplicitMetadataSourceAndWrongAttributeShouldHaveDiagnostic() + { + var test = GetTestCode( + """ + [EnumExtensions(MetadataSource = MetadataSource.DisplayAttribute)] + public enum TestEnum + { + [{|NEEG013:EnumMember(Value = "first")|}] + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithDescriptionAttributeWhenExpectingDisplayShouldHaveDiagnostic() + { + var test = GetTestCode( + """ + [EnumExtensions(MetadataSource = MetadataSource.DisplayAttribute)] + public enum TestEnum + { + [{|NEEG013:Description("First description")|}] + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task EnumWithDisplayAttributeWhenExpectingDescriptionShouldHaveDiagnostic() + { + var test = GetTestCode( + """ + [EnumExtensions(MetadataSource = MetadataSource.DescriptionAttribute)] + public enum TestEnum + { + [{|NEEG013:Display(Name = "First")|}] + First, + Second, + } + """); + await Verifier.VerifyAnalyzerAsync(test); + } + + [Fact(Skip = "Global metadata source tests need special test infrastructure")] + public async Task EnumWithGlobalMetadataSourceShouldRespectIt() + { + var options = new System.Collections.Generic.Dictionary + { + [$"build_property.{Constants.MetadataSourcePropertyName}"] = "DisplayAttribute" + }; + + var test = GetTestCodeWithOptions( + options, + """ + [EnumExtensions] + public enum TestEnum + { + [{|NEEG013:EnumMember(Value = "first")|}] + First, + Second, + } + """); + + await test.RunAsync(); + } + + [Fact(Skip = "Global metadata source tests need special test infrastructure")] + public async Task EnumWithGlobalMetadataSourceAndCorrectAttributeShouldNotHaveDiagnostic() + { + var options = new System.Collections.Generic.Dictionary + { + [$"build_property.{Constants.MetadataSourcePropertyName}"] = "DisplayAttribute" + }; + + var test = GetTestCodeWithOptions( + options, + """ + [EnumExtensions] + public enum TestEnum + { + [Display(Name = "First")] + First, + Second, + } + """); + + await test.RunAsync(); + } + + [Fact(Skip = "Global metadata source tests need special test infrastructure")] + public async Task EnumWithExplicitMetadataSourceOverridesGlobal() + { + // Global is DisplayAttribute, but explicit is EnumMemberAttribute + var options = new System.Collections.Generic.Dictionary + { + [$"build_property.{Constants.MetadataSourcePropertyName}"] = "DisplayAttribute" + }; + + var test = GetTestCodeWithOptions( + options, + """ + [EnumExtensions(MetadataSource = MetadataSource.EnumMemberAttribute)] + public enum TestEnum + { + [EnumMember(Value = "first")] + First, + Second, + } + """); + + await test.RunAsync(); + } + + private static string GetTestCode(string testFragment) + => $$""" + using System; + using System.ComponentModel; + using System.ComponentModel.DataAnnotations; + using System.Runtime.Serialization; + using NetEscapades.EnumGenerators; + + {{testFragment}} + + {{TestHelpers.LoadEmbeddedAttribute()}} + {{TestHelpers.LoadEmbeddedMetadataSource()}} + """; + + private static Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest GetTestCodeWithOptions( + System.Collections.Generic.Dictionary options, + string testFragment) + { + var test = new Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest + { + TestCode = GetTestCode(testFragment), + SolutionTransforms = + { + (solution, projectId) => + { + var compilationOptions = solution.GetProject(projectId)!.CompilationOptions; + compilationOptions = compilationOptions!.WithSpecificDiagnosticOptions( + compilationOptions.SpecificDiagnosticOptions.SetItems( + System.Collections.Immutable.ImmutableDictionary.Empty)); + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions); + + var analyzerConfigDocumentId = Microsoft.CodeAnalysis.DocumentId.CreateNewId(projectId); + var text = "[*]\r\n"; + foreach (var kvp in options) + { + text += $"{kvp.Key} = {kvp.Value}\r\n"; + } + solution = solution.AddAnalyzerConfigDocument( + analyzerConfigDocumentId, + ".editorconfig", + Microsoft.CodeAnalysis.Text.SourceText.From(text), + filePath: "/.editorconfig"); + + return solution; + } + } + }; + + return test; + } +}