diff --git a/src/DataverseAnalyzer/EnumAssignmentAnalyzer.cs b/src/DataverseAnalyzer/EnumAssignmentAnalyzer.cs new file mode 100644 index 0000000..e75a1d2 --- /dev/null +++ b/src/DataverseAnalyzer/EnumAssignmentAnalyzer.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EnumAssignmentAnalyzer : DiagnosticAnalyzer +{ + public static readonly DiagnosticDescriptor Rule = new( + "CT0002", + Resources.CT0002_Title, + Resources.CT0002_MessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Resources.CT0002_Description); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + ArgumentNullException.ThrowIfNull(context); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeAssignmentExpression, SyntaxKind.SimpleAssignmentExpression); + context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, SyntaxKind.PropertyDeclaration); + } + + private static void AnalyzeAssignmentExpression(SyntaxNodeAnalysisContext context) + { + var assignment = (AssignmentExpressionSyntax)context.Node; + AnalyzeEnumAssignment(context, assignment.Left, assignment.Right); + } + + private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context) + { + var property = (PropertyDeclarationSyntax)context.Node; + if (property.Initializer is not null) + { + AnalyzeEnumAssignmentForProperty(context, property, property.Initializer.Value); + } + } + + private static void AnalyzeEnumAssignmentForProperty(SyntaxNodeAnalysisContext context, PropertyDeclarationSyntax property, ExpressionSyntax right) + { + // Check if the right side is a numeric literal + if (right is not LiteralExpressionSyntax literal || !IsNumericLiteral(literal)) + { + return; + } + + // Get the type of the property from its declaration + var propertyTypeInfo = context.SemanticModel.GetTypeInfo(property.Type); + var targetType = propertyTypeInfo.Type; + + // Handle nullable enum types + if (targetType is null) + { + return; + } + + if (targetType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + targetType = ((INamedTypeSymbol)targetType).TypeArguments[0]; + } + + // Check if the target type is an enum + if (targetType.TypeKind != TypeKind.Enum) + { + return; + } + + var targetName = property.Identifier.ValueText; + var literalValue = literal.Token.ValueText; + + var diagnostic = Diagnostic.Create(Rule, right.GetLocation(), targetName, literalValue); + context.ReportDiagnostic(diagnostic); + } + + private static void AnalyzeEnumAssignment(SyntaxNodeAnalysisContext context, SyntaxNode left, ExpressionSyntax right) + { + // Check if the right side is a numeric literal + if (right is not LiteralExpressionSyntax literal || !IsNumericLiteral(literal)) + { + return; + } + + // Get the type of the left side + var leftTypeInfo = context.SemanticModel.GetTypeInfo(left); + var targetType = leftTypeInfo.Type; + + // Handle nullable enum types + if (targetType is null) + { + return; + } + + if (targetType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + targetType = ((INamedTypeSymbol)targetType).TypeArguments[0]; + } + + // Check if the target type is an enum + if (targetType.TypeKind != TypeKind.Enum) + { + return; + } + + var targetName = GetTargetName(left); + var literalValue = literal.Token.ValueText; + + var diagnostic = Diagnostic.Create(Rule, right.GetLocation(), targetName, literalValue); + context.ReportDiagnostic(diagnostic); + } + + private static bool IsNumericLiteral(LiteralExpressionSyntax literal) + { + return literal.Token.IsKind(SyntaxKind.NumericLiteralToken); + } + + private static string GetTargetName(SyntaxNode left) + { + return left switch + { + PropertyDeclarationSyntax property => property.Identifier.ValueText, + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText, + _ => "property", + }; + } +} \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.Designer.cs b/src/DataverseAnalyzer/Resources.Designer.cs index 160e245..78bd77c 100644 --- a/src/DataverseAnalyzer/Resources.Designer.cs +++ b/src/DataverseAnalyzer/Resources.Designer.cs @@ -15,5 +15,11 @@ internal static class Resources internal static string CT0001_CodeFix_Title => GetString(nameof(CT0001_CodeFix_Title)); + internal static string CT0002_Title => GetString(nameof(CT0002_Title)); + + internal static string CT0002_MessageFormat => GetString(nameof(CT0002_MessageFormat)); + + internal static string CT0002_Description => GetString(nameof(CT0002_Description)); + private static string GetString(string name) => ResourceManager.GetString(name, CultureInfo.InvariantCulture) ?? name; } \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.resx b/src/DataverseAnalyzer/Resources.resx index db1214b..037663b 100644 --- a/src/DataverseAnalyzer/Resources.resx +++ b/src/DataverseAnalyzer/Resources.resx @@ -70,4 +70,13 @@ Add braces + + Enum properties should not be assigned literal values + + + Enum property '{0}' should not be assigned literal value '{1}'. Use the appropriate enum value instead + + + Enum properties should be assigned enum values rather than literal numeric values to improve code readability and maintainability. + \ No newline at end of file diff --git a/tests/DataverseAnalyzer.Tests/EnumAssignmentAnalyzerTests.cs b/tests/DataverseAnalyzer.Tests/EnumAssignmentAnalyzerTests.cs new file mode 100644 index 0000000..7b12c90 --- /dev/null +++ b/tests/DataverseAnalyzer.Tests/EnumAssignmentAnalyzerTests.cs @@ -0,0 +1,287 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; + +namespace DataverseAnalyzer.Tests; + +public sealed class EnumAssignmentAnalyzerTests +{ + [Fact] + public async Task EnumPropertyAssignedEnumValueShouldNotTrigger() + { + var source = """ + public enum AccountCategoryCode + { + Standard = 1, + Preferred = 2 + } + + class TestClass + { + public AccountCategoryCode? AccountCategoryCode { get; set; } + + public void TestMethod() + { + AccountCategoryCode = AccountCategoryCode.Standard; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task EnumPropertyAssignedLiteralShouldTrigger() + { + var source = """ + public enum AccountCategoryCode + { + Standard = 1, + Preferred = 2 + } + + class TestClass + { + public AccountCategoryCode? AccountCategoryCode { get; set; } + + public void TestMethod() + { + AccountCategoryCode = 2; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0002", diagnostics[0].Id); + } + + [Fact] + public async Task NonNullableEnumPropertyAssignedLiteralShouldTrigger() + { + var source = """ + public enum Status + { + Active = 1, + Inactive = 2 + } + + class TestClass + { + public Status Status { get; set; } + + public void TestMethod() + { + Status = 1; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0002", diagnostics[0].Id); + } + + [Fact] + public async Task PropertyInitializerWithLiteralShouldTrigger() + { + var source = """ + public enum Priority + { + Low = 1, + High = 2 + } + + class TestClass + { + public Priority Priority { get; set; } = 1; + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0002", diagnostics[0].Id); + } + + [Fact] + public async Task PropertyInitializerWithEnumValueShouldNotTrigger() + { + var source = """ + public enum Priority + { + Low = 1, + High = 2 + } + + class TestClass + { + public Priority Priority { get; set; } = Priority.Low; + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task NonEnumPropertyAssignedLiteralShouldNotTrigger() + { + var source = """ + class TestClass + { + public int Number { get; set; } + + public void TestMethod() + { + Number = 42; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task EnumFieldAssignedLiteralShouldTrigger() + { + var source = """ + public enum Color + { + Red = 1, + Blue = 2 + } + + class TestClass + { + private Color color; + + public void TestMethod() + { + color = 2; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0002", diagnostics[0].Id); + } + + [Fact] + public async Task EnumPropertyAssignedVariableShouldNotTrigger() + { + var source = """ + public enum Status + { + Active = 1, + Inactive = 2 + } + + class TestClass + { + public Status Status { get; set; } + + public void TestMethod() + { + var value = 1; + Status = value; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task EnumPropertyAssignedCastShouldNotTrigger() + { + var source = """ + public enum Status + { + Active = 1, + Inactive = 2 + } + + class TestClass + { + public Status Status { get; set; } + + public void TestMethod() + { + Status = (Status)1; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task EnumPropertyAssignedStringLiteralShouldNotTrigger() + { + var source = """ + class TestClass + { + public string Name { get; set; } + + public void TestMethod() + { + Name = "test"; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task MemberAccessEnumAssignedLiteralShouldTrigger() + { + var source = """ + public enum AccountCategoryCode + { + Standard = 1, + Preferred = 2 + } + + class Account + { + public AccountCategoryCode? AccountCategoryCode { get; set; } + } + + class TestClass + { + public void TestMethod() + { + var account = new Account(); + account.AccountCategoryCode = 2; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0002", diagnostics[0].Id); + } + + private static async Task GetDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] { syntaxTree }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }); + + var analyzer = new EnumAssignmentAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + return diagnostics.Where(d => d.Id == "CT0002").ToArray(); + } +} \ No newline at end of file