From 841e9e25fb7e6bd8b8336189d556066b13e9f5a9 Mon Sep 17 00:00:00 2001 From: Piotr Kryczka Date: Wed, 24 Sep 2025 14:19:33 +0200 Subject: [PATCH 1/5] Add enum dto consistency analyzer --- .../Analyzers/EnumDtoConsistencyAnalyzer.cs | 342 +++++++++++++++ .../SynchronizeEnumDtoCodeAction.cs | 397 ++++++++++++++++++ .../SynchronizeEnumDtoCodeFixProvider.cs | 24 ++ .../LeanCode.CodeAnalysis/DiagnosticsIds.cs | 1 + .../EnumDtoAttributes.cs | 76 ++++ .../EnumDtoConsistencyAnalyzerTests.cs | 370 ++++++++++++++++ .../SynchronizeEnumDtoCodeActionTests.cs | 346 +++++++++++++++ 7 files changed, 1556 insertions(+) create mode 100644 src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs create mode 100644 src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs create mode 100644 src/Tools/LeanCode.CodeAnalysis/CodeFixProviders/SynchronizeEnumDtoCodeFixProvider.cs create mode 100644 src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs create mode 100644 test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs create mode 100644 test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs diff --git a/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs b/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs new file mode 100644 index 000000000..e81772a21 --- /dev/null +++ b/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs @@ -0,0 +1,342 @@ +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace LeanCode.CodeAnalysis.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class EnumDtoConsistencyAnalyzer : DiagnosticAnalyzer +{ + private const string Category = "Design"; + private const string DtoSuffix = "DTO"; + private const string IgnoreEnumDtoAttributeName = "LeanCode.CodeAnalysis.IgnoreEnumDtoAttribute"; + private const string ExcludeMembersAttributeName = "LeanCode.CodeAnalysis.ExcludeMembersAttribute"; + private const string IgnoreEnumValueAttributeName = "LeanCode.CodeAnalysis.IgnoreEnumValueAttribute"; + private const string EnumValueCorrespondsAttributeName = "LeanCode.CodeAnalysis.EnumValueCorrespondsAttribute"; + + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + DiagnosticsIds.EnumDtoShouldMatchBaseEnum, + "DTO enum should match its base enum", + "DTO enum '{0}' should have the same members as its base enum '{1}'. Missing: [{2}]. Extra: [{3}]. Value mismatches: [{4}].", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "DTO enums should have the same members (names and values) as their corresponding base enums, unless explicitly configured with attributes." + ); + + public override ImmutableArray SupportedDiagnostics { get; } = [Rule]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis( + GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics + ); + context.RegisterSymbolAction(AnalyzeEnum, SymbolKind.NamedType); + } + + private static void AnalyzeEnum(SymbolAnalysisContext context) + { + var enumSymbol = (INamedTypeSymbol)context.Symbol; + + if (enumSymbol.TypeKind != TypeKind.Enum) + { + return; + } + + if (!enumSymbol.Name.EndsWith(DtoSuffix, StringComparison.Ordinal)) + { + return; + } + + if (HasIgnoreEnumDtoAttribute(enumSymbol)) + { + return; + } + + var baseEnumName = enumSymbol.Name[..^DtoSuffix.Length]; + var baseEnum = FindBaseEnum(enumSymbol, baseEnumName); + + if (baseEnum == null) + { + return; + } + + var analysis = AnalyzeEnumConsistency(enumSymbol, baseEnum); + + if (analysis.HasIssues) + { + var diagnostic = Diagnostic.Create( + Rule, + enumSymbol.Locations[0], + enumSymbol.Name, + baseEnum.Name, + string.Join(", ", analysis.MissingMembers), + string.Join(", ", analysis.ExtraMembers), + string.Join(", ", analysis.ValueMismatches) + ); + + context.ReportDiagnostic(diagnostic); + } + } + + private static bool HasIgnoreEnumDtoAttribute(INamedTypeSymbol enumSymbol) + { + return enumSymbol + .GetAttributes() + .Any(attr => attr.AttributeClass?.GetFullNamespaceName() == IgnoreEnumDtoAttributeName); + } + + private static INamedTypeSymbol? FindBaseEnum(INamedTypeSymbol dtoEnum, string baseEnumName) + { + var sameNamespaceEnum = dtoEnum + .ContainingNamespace.GetTypeMembers(baseEnumName) + .FirstOrDefault(t => t.TypeKind == TypeKind.Enum); + + if (sameNamespaceEnum is not null) + { + return sameNamespaceEnum; + } + + return FindEnumInCompilation(dtoEnum.ContainingAssembly.GlobalNamespace, baseEnumName); + } + + private static INamedTypeSymbol? FindEnumInCompilation(INamespaceSymbol namespaceSymbol, string enumName) + { + var enumType = namespaceSymbol.GetTypeMembers(enumName).FirstOrDefault(t => t.TypeKind == TypeKind.Enum); + + if (enumType is not null) + { + return enumType; + } + + foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers()) + { + var result = FindEnumInCompilation(childNamespace, enumName); + if (result is not null) + { + return result; + } + } + + return null; + } + + private static EnumConsistencyAnalysis AnalyzeEnumConsistency(INamedTypeSymbol dtoEnum, INamedTypeSymbol baseEnum) + { + var analysis = new EnumConsistencyAnalysis(); + + var excludedMembers = GetExcludedMembers(dtoEnum, baseEnum); + + var baseMembers = baseEnum + .GetMembers() + .OfType() + .Where(f => f.IsStatic && f.HasConstantValue && !excludedMembers.Contains(f.Name)) + .ToDictionary(f => f.Name, f => f); + + var dtoMembers = dtoEnum + .GetMembers() + .OfType() + .Where(f => f.IsStatic && f.HasConstantValue) + .ToDictionary(f => f.Name, f => f); + + var accountedBaseMembers = new HashSet(); + + foreach (var dtoMember in dtoMembers.Values) + { + if (HasIgnoreEnumValueAttribute(dtoMember)) + { + continue; + } + + var correspondsAttr = GetEnumValueCorrespondsAttribute(dtoMember); + + if (correspondsAttr is not null) + { + var correspondingNames = GetCorrespondingEnumNames(correspondsAttr, baseEnum); + + foreach (var correspondingName in correspondingNames) + { + if (baseMembers.TryGetValue(correspondingName, out var baseMember)) + { + accountedBaseMembers.Add(correspondingName); + } + } + } + else + { + if (baseMembers.TryGetValue(dtoMember.Name, out var baseMember)) + { + accountedBaseMembers.Add(dtoMember.Name); + + if (!AreEnumValuesEqual(dtoMember.ConstantValue, baseMember.ConstantValue)) + { + analysis.ValueMismatches.Add( + $"{dtoMember.Name} ({dtoMember.ConstantValue} != {baseMember.ConstantValue})" + ); + } + } + else + { + analysis.ExtraMembers.Add(dtoMember.Name); + } + } + } + + foreach (var baseMember in baseMembers.Values) + { + if (!accountedBaseMembers.Contains(baseMember.Name)) + { + analysis.MissingMembers.Add(baseMember.Name); + } + } + + return analysis; + } + + private static HashSet GetExcludedMembers(INamedTypeSymbol dtoEnum, INamedTypeSymbol baseEnum) + { + var excludedMembers = new HashSet(); + + var excludeAttr = dtoEnum + .GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.GetFullNamespaceName() == ExcludeMembersAttributeName); + + if (excludeAttr?.ConstructorArguments.Length > 0) + { + foreach (var arg in excludeAttr.ConstructorArguments) + { + if (arg.Kind == TypedConstantKind.Array && arg.Values.Length > 0) + { + foreach (var value in arg.Values) + { + if (value.Value is not null) + { + var memberName = GetEnumMemberNameByValue(baseEnum, value.Value); + if (memberName is not null) + { + excludedMembers.Add(memberName); + } + } + } + } + else if (arg.Value is not null) + { + var memberName = GetEnumMemberNameByValue(baseEnum, arg.Value); + if (memberName is not null) + { + excludedMembers.Add(memberName); + } + } + } + } + + return excludedMembers; + } + + private static string? GetEnumMemberNameByValue(INamedTypeSymbol enumSymbol, object value) + { + return enumSymbol + .GetMembers() + .OfType() + .Where(f => f.IsStatic && f.HasConstantValue) + .FirstOrDefault(f => AreEnumValuesEqual(f.ConstantValue, value)) + ?.Name; + } + + private static bool HasIgnoreEnumValueAttribute(IFieldSymbol field) + { + return field + .GetAttributes() + .Any(attr => attr.AttributeClass?.GetFullNamespaceName() == IgnoreEnumValueAttributeName); + } + + private static AttributeData? GetEnumValueCorrespondsAttribute(IFieldSymbol field) + { + return field + .GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.GetFullNamespaceName() == EnumValueCorrespondsAttributeName); + } + + private static List GetCorrespondingEnumNames(AttributeData correspondsAttr, INamedTypeSymbol baseEnum) + { + var names = new List(); + + for (var i = 0; i < correspondsAttr.ConstructorArguments.Length; i++) + { + var arg = correspondsAttr.ConstructorArguments[i]; + + if (arg.Kind == TypedConstantKind.Array && arg.Values.Length > 0) + { + foreach (var value in arg.Values) + { + if (value.Value is not null) + { + var memberName = GetEnumMemberNameByValue(baseEnum, value.Value); + if (memberName is not null) + { + names.Add(memberName); + } + } + } + } + else if (arg.Value is not null) + { + var memberName = GetEnumMemberNameByValue(baseEnum, arg.Value); + if (memberName is not null) + { + names.Add(memberName); + } + } + } + + return names; + } + + private static bool AreEnumValuesEqual(object? value1, object? value2) + { + if (value1 == null && value2 == null) + { + return true; + } + if (value1 == null || value2 == null) + { + return false; + } + + // This handles cases where enums have different underlying types (byte, short, int, etc.) + if (value1.GetType() != value2.GetType()) + { + try + { + var converted1 = Convert.ToInt64(value1, CultureInfo.InvariantCulture); + var converted2 = Convert.ToInt64(value2, CultureInfo.InvariantCulture); + return converted1 == converted2; + } + catch (FormatException) + { + return false; + } + catch (OverflowException) + { + return false; + } + catch (InvalidCastException) + { + return false; + } + } + + return value1.Equals(value2); + } + + private sealed class EnumConsistencyAnalysis + { + public List MissingMembers { get; } = []; + public List ExtraMembers { get; } = []; + public List ValueMismatches { get; } = []; + + public bool HasIssues => MissingMembers.Count > 0 || ExtraMembers.Count > 0 || ValueMismatches.Count > 0; + } +} diff --git a/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs b/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs new file mode 100644 index 000000000..e7ea08da5 --- /dev/null +++ b/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs @@ -0,0 +1,397 @@ +using System.Globalization; +using LeanCode.CodeAnalysis.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace LeanCode.CodeAnalysis.CodeActions; + +public class SynchronizeEnumDtoCodeAction : CodeAction +{ + private const string DtoSuffix = "DTO"; + private const string ExcludeMembersAttributeName = "LeanCode.CodeAnalysis.ExcludeMembersAttribute"; + private const string IgnoreEnumValueAttributeName = "LeanCode.CodeAnalysis.IgnoreEnumValueAttribute"; + private const string EnumValueCorrespondsAttributeName = "LeanCode.CodeAnalysis.EnumValueCorrespondsAttribute"; + + private readonly Document document; + private readonly TextSpan enumSpan; + + public override string Title => "Synchronize DTO enum with base enum"; + public override string EquivalenceKey => Title; + + public SynchronizeEnumDtoCodeAction(Document document, TextSpan enumSpan) + { + this.document = document; + this.enumSpan = enumSpan; + } + + protected override async Task GetChangedDocumentAsync(CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + var model = await document.GetSemanticModelAsync(cancellationToken); + + if (root is null || model is null) + { + return document; + } + + var enumDeclaration = root.FindNode(enumSpan).FirstAncestorOrSelf(); + if (enumDeclaration is null) + { + return document; + } + + if ( + model.GetDeclaredSymbol(enumDeclaration, cancellationToken) is not INamedTypeSymbol enumSymbol + || !enumSymbol.Name.EndsWith(DtoSuffix, StringComparison.Ordinal) + ) + { + return document; + } + + var baseEnumName = enumSymbol.Name[..^DtoSuffix.Length]; + var baseEnum = FindBaseEnum(enumSymbol, baseEnumName); + + if (baseEnum is null) + { + return document; + } + + var synchronizedEnum = GenerateSynchronizedEnum(enumDeclaration, enumSymbol, baseEnum, model); + var newRoot = root.ReplaceNode(enumDeclaration, synchronizedEnum); + + return document.WithSyntaxRoot(newRoot); + } + + private static INamedTypeSymbol? FindBaseEnum(INamedTypeSymbol dtoEnum, string baseEnumName) + { + var sameNamespaceEnum = dtoEnum + .ContainingNamespace.GetTypeMembers(baseEnumName) + .FirstOrDefault(t => t.TypeKind == TypeKind.Enum); + + if (sameNamespaceEnum is not null) + { + return sameNamespaceEnum; + } + + return FindEnumInCompilation(dtoEnum.ContainingAssembly.GlobalNamespace, baseEnumName); + } + + private static INamedTypeSymbol? FindEnumInCompilation(INamespaceSymbol namespaceSymbol, string enumName) + { + var enumType = namespaceSymbol.GetTypeMembers(enumName).FirstOrDefault(t => t.TypeKind == TypeKind.Enum); + + if (enumType is not null) + { + return enumType; + } + + foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers()) + { + var result = FindEnumInCompilation(childNamespace, enumName); + if (result is not null) + { + return result; + } + } + + return null; + } + + private static EnumDeclarationSyntax GenerateSynchronizedEnum( + EnumDeclarationSyntax originalEnum, + INamedTypeSymbol enumSymbol, + INamedTypeSymbol baseEnum, + SemanticModel model + ) + { + var excludedMembers = GetExcludedMembers(enumSymbol, baseEnum); + + var existingMembers = originalEnum.Members.ToDictionary(m => m.Identifier.ValueText, m => m); + + var newMembers = new List(); + + // Existing DTO members that have special attributes (Ignore or Corresponds) should be preserved as-is + foreach (var existingMember in existingMembers.Values) + { + if (HasSpecialAttributes(existingMember, model)) + { + newMembers.Add(existingMember); + } + } + + var baseMembers = baseEnum + .GetMembers() + .OfType() + .Where(f => f.IsStatic && f.HasConstantValue && !excludedMembers.Contains(f.Name)) + .OrderBy(f => Convert.ToInt64(f.ConstantValue, CultureInfo.InvariantCulture)) + .ToList(); + + foreach (var baseMember in baseMembers) + { + if (IsBaseMemberCoveredByCorrespondsAttribute(baseMember, existingMembers, model)) + { + continue; + } + + if (existingMembers.TryGetValue(baseMember.Name, out var existingMember)) + { + if (HasEnumValueCorrespondsAttribute(existingMember, model)) + { + continue; + } + + var currentValue = GetEnumMemberValue(existingMember); + var expectedValue = Convert.ToInt64(baseMember.ConstantValue, CultureInfo.InvariantCulture); + + if (currentValue != expectedValue) + { + var newMember = existingMember.WithEqualsValue( + SyntaxFactory.EqualsValueClause( + SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal((int)expectedValue) + ) + ) + ); + newMembers.Add(newMember); + } + else + { + newMembers.Add(existingMember); + } + } + else + { + var newMember = SyntaxFactory + .EnumMemberDeclaration(baseMember.Name) + .WithEqualsValue( + SyntaxFactory.EqualsValueClause( + SyntaxFactory.LiteralExpression( + SyntaxKind.NumericLiteralExpression, + SyntaxFactory.Literal( + (int)Convert.ToInt64(baseMember.ConstantValue, CultureInfo.InvariantCulture) + ) + ) + ) + ); + + newMembers.Add(newMember); + } + } + + newMembers = newMembers.OrderBy(GetEnumMemberValue).ToList(); + + return originalEnum.WithMembers(SyntaxFactory.SeparatedList(newMembers)); + } + + private static HashSet GetExcludedMembers(INamedTypeSymbol dtoEnum, INamedTypeSymbol baseEnum) + { + var excludedMembers = new HashSet(); + + var excludeAttr = dtoEnum + .GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.GetFullNamespaceName() == ExcludeMembersAttributeName); + + if (excludeAttr?.ConstructorArguments.Length > 0) + { + foreach (var arg in excludeAttr.ConstructorArguments) + { + if (arg.Kind == TypedConstantKind.Array && arg.Values.Length > 0) + { + foreach (var value in arg.Values) + { + if (value.Value is not null) + { + var memberName = GetEnumMemberNameByValue(baseEnum, value.Value); + if (memberName is not null) + { + excludedMembers.Add(memberName); + } + } + } + } + else if (arg.Value is not null) + { + var memberName = GetEnumMemberNameByValue(baseEnum, arg.Value); + if (memberName is not null) + { + excludedMembers.Add(memberName); + } + } + } + } + + return excludedMembers; + } + + private static string? GetEnumMemberNameByValue(INamedTypeSymbol enumSymbol, object value) + { + return enumSymbol + .GetMembers() + .OfType() + .Where(f => f.IsStatic && f.HasConstantValue) + .FirstOrDefault(f => AreEnumValuesEqual(f.ConstantValue, value)) + ?.Name; + } + + private static bool AreEnumValuesEqual(object? value1, object? value2) + { + if (value1 is null && value2 is null) + { + return true; + } + + if (value1 is null || value2 is null) + { + return false; + } + + // This handles cases where enums have different underlying types (byte, short, int, etc.) + if (value1.GetType() != value2.GetType()) + { + try + { + var converted1 = Convert.ToInt64(value1, CultureInfo.InvariantCulture); + var converted2 = Convert.ToInt64(value2, CultureInfo.InvariantCulture); + return converted1 == converted2; + } + catch (FormatException) + { + return false; + } + catch (OverflowException) + { + return false; + } + catch (InvalidCastException) + { + return false; + } + } + + return value1.Equals(value2); + } + + private static long GetEnumMemberValue(EnumMemberDeclarationSyntax member) + { + if ( + member.EqualsValue?.Value is LiteralExpressionSyntax literal + && literal.Token.IsKind(SyntaxKind.NumericLiteralToken) + ) + { + if (long.TryParse(literal.Token.ValueText, out var value)) + { + return value; + } + } + + return 0; // Default value if not specified + } + + private static bool HasSpecialAttributes(EnumMemberDeclarationSyntax member, SemanticModel model) + { + foreach (var attributeList in member.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var symbolInfo = model.GetSymbolInfo(attribute); + if (symbolInfo.Symbol is IMethodSymbol method) + { + var attributeTypeName = method.ContainingType.GetFullNamespaceName(); + if ( + attributeTypeName == IgnoreEnumValueAttributeName + || attributeTypeName == EnumValueCorrespondsAttributeName + ) + { + return true; + } + } + } + } + + return false; + } + + private static bool HasEnumValueCorrespondsAttribute(EnumMemberDeclarationSyntax member, SemanticModel model) + { + foreach (var attributeList in member.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var symbolInfo = model.GetSymbolInfo(attribute); + if (symbolInfo.Symbol is IMethodSymbol method) + { + var attributeTypeName = method.ContainingType.GetFullNamespaceName(); + if (attributeTypeName == EnumValueCorrespondsAttributeName) + { + return true; + } + } + } + } + + return false; + } + + private static bool IsBaseMemberCoveredByCorrespondsAttribute( + IFieldSymbol baseMember, + Dictionary existingMembers, + SemanticModel model + ) + { + foreach (var existingMember in existingMembers.Values) + { + if (!HasEnumValueCorrespondsAttribute(existingMember, model)) + { + continue; + } + + var correspondingValues = GetCorrespondingValuesFromAttribute(existingMember, model); + if (correspondingValues.Contains(baseMember.Name)) + { + return true; + } + } + + return false; + } + + private static HashSet GetCorrespondingValuesFromAttribute( + EnumMemberDeclarationSyntax member, + SemanticModel model + ) + { + var correspondingValues = new HashSet(); + + foreach (var attributeList in member.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var symbolInfo = model.GetSymbolInfo(attribute); + if (symbolInfo.Symbol is IMethodSymbol method) + { + var attributeTypeName = method.ContainingType.GetFullNamespaceName(); + if (attributeTypeName == EnumValueCorrespondsAttributeName) + { + if (attribute.ArgumentList?.Arguments.Count > 0) + { + foreach (var argument in attribute.ArgumentList.Arguments) + { + if (argument.Expression is MemberAccessExpressionSyntax memberAccess) + { + var memberName = memberAccess.Name.Identifier.ValueText; + correspondingValues.Add(memberName); + } + } + } + } + } + } + } + + return correspondingValues; + } +} diff --git a/src/Tools/LeanCode.CodeAnalysis/CodeFixProviders/SynchronizeEnumDtoCodeFixProvider.cs b/src/Tools/LeanCode.CodeAnalysis/CodeFixProviders/SynchronizeEnumDtoCodeFixProvider.cs new file mode 100644 index 000000000..f5bca862a --- /dev/null +++ b/src/Tools/LeanCode.CodeAnalysis/CodeFixProviders/SynchronizeEnumDtoCodeFixProvider.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; +using System.Composition; +using LeanCode.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace LeanCode.CodeAnalysis.CodeFixProviders; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SynchronizeEnumDtoCodeFixProvider))] +[Shared] +public class SynchronizeEnumDtoCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DiagnosticsIds.EnumDtoShouldMatchBaseEnum); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + context.RegisterCodeFix(new SynchronizeEnumDtoCodeAction(context.Document, context.Span), context.Diagnostics); + + return Task.CompletedTask; + } +} diff --git a/src/Tools/LeanCode.CodeAnalysis/DiagnosticsIds.cs b/src/Tools/LeanCode.CodeAnalysis/DiagnosticsIds.cs index 53ff0d0da..c183edf44 100644 --- a/src/Tools/LeanCode.CodeAnalysis/DiagnosticsIds.cs +++ b/src/Tools/LeanCode.CodeAnalysis/DiagnosticsIds.cs @@ -13,4 +13,5 @@ public static class DiagnosticsIds public const string OperationHandlersShouldFollowNamingConvention = "LNCD0009"; public const string CommandValidatorsShouldFollowNamingConvention = "LNCD0010"; public const string CQRSHandlersShouldBeInProperNamespace = "LNCD0011"; + public const string EnumDtoShouldMatchBaseEnum = "LNCD0012"; } diff --git a/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs b/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs new file mode 100644 index 000000000..e9d6836c8 --- /dev/null +++ b/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs @@ -0,0 +1,76 @@ +namespace LeanCode.CodeAnalysis; + +/// +/// Attribute that marks an enum ending with "DTO" to be completely ignored +/// by the enum DTO consistency analyzer. +/// +/// +/// This attribute can only be applied to enums whose names end with "DTO". +/// When applied, the enum will not be checked for consistency with its base enum. +/// +[AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] +public sealed class IgnoreEnumDtoAttribute : Attribute { } + +/// +/// Attribute that specifies which enum values from the base enum should be excluded +/// when checking consistency with the DTO enum. +/// +/// +/// This attribute can only be applied to enums whose names end with "DTO". +/// The excluded values are specified as parameters and correspond to values +/// from the base enum (without "DTO" suffix). +/// +[AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] +public sealed class ExcludeMembersAttribute : Attribute +{ + /// + /// The enum values from the base enum that should be excluded from consistency checking. + /// + public object[] IgnoredValues { get; } + + /// + /// Initializes a new instance of the ExcludeMembersAttribute class. + /// + /// The enum values to exclude from consistency checking. + public ExcludeMembersAttribute(params object[] ignoredValues) + { + IgnoredValues = ignoredValues; + } +} + +/// +/// Attribute that marks a specific enum value in a DTO enum to be ignored +/// during consistency checking with the base enum. +/// +/// +/// This attribute can only be applied to enum values within enums whose names end with "DTO". +/// When applied, the analyzer will not require a corresponding value in the base enum. +/// +[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] +public sealed class IgnoreEnumValueAttribute : Attribute { } + +/// +/// Attribute that specifies which enum value(s) from the base enum this DTO enum value corresponds to. +/// +/// +/// This attribute can only be applied to enum values within enums whose names end with "DTO". +/// When applied, the analyzer will check that this DTO enum value matches the specified base enum value(s) +/// instead of requiring a matching name and value. +/// +[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] +public sealed class EnumValueCorrespondsAttribute : Attribute +{ + /// + /// The enum values from the base enum that this DTO enum value corresponds to. + /// + public object[] CorrespondingValues { get; } + + /// + /// Initializes a new instance of the EnumValueCorrespondsAttribute class. + /// + /// The base enum values this DTO enum value corresponds to. + public EnumValueCorrespondsAttribute(params object[] correspondingValues) + { + CorrespondingValues = correspondingValues; + } +} diff --git a/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs new file mode 100644 index 000000000..97a03b027 --- /dev/null +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs @@ -0,0 +1,370 @@ +using LeanCode.CodeAnalysis.Analyzers; +using LeanCode.CodeAnalysis.Tests.Verifiers; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace LeanCode.CodeAnalysis.Tests.Analyzers; + +public class EnumDtoConsistencyAnalyzerTests : DiagnosticVerifier +{ + private const string AttributeDefinitions = + @" +using System; + +namespace LeanCode.CodeAnalysis +{ + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumDtoAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class ExcludeMembersAttribute : Attribute + { + public object[] IgnoredValues { get; } + public ExcludeMembersAttribute(params object[] ignoredValues) + { + IgnoredValues = ignoredValues; + } + } + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumValueAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class EnumValueCorrespondsAttribute : Attribute + { + public object[] CorrespondingValues { get; } + public EnumValueCorrespondsAttribute(params object[] correspondingValues) + { + CorrespondingValues = correspondingValues; + } + } +} +"; + + [Fact] + public async Task Matching_enum_dto_with_base_enum_should_pass() + { + var source = + @" +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Enum_dto_missing_members_should_fail() + { + var source = + @"namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2 +}"; + + var diags = new[] { new DiagnosticResult(DiagnosticsIds.EnumDtoShouldMatchBaseEnum, 10, 12) }; + await VerifyDiagnostics(source, diags); + } + + [Fact] + public async Task Enum_dto_with_exclude_members_attribute_should_pass() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +[LeanCode.CodeAnalysis.ExcludeMembers(Status.Completed, Status.Cancelled)] +public enum StatusDTO +{ + None = 0, + InProgress = 1, +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Enum_dto_with_extra_members_should_fail() + { + var source = + @"namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + Deleted = 4 +}"; + + var diags = new[] { new DiagnosticResult(DiagnosticsIds.EnumDtoShouldMatchBaseEnum, 10, 12) }; + await VerifyDiagnostics(source, diags); + } + + [Fact] + public async Task Enum_dto_with_ignore_attribute_on_extra_member_should_pass() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + Deleted = 4 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Enum_dto_with_value_mismatch_should_fail() + { + var source = + @"namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 4 +}"; + + var diags = new[] { new DiagnosticResult(DiagnosticsIds.EnumDtoShouldMatchBaseEnum, 10, 12) }; + await VerifyDiagnostics(source, diags); + } + + [Fact] + public async Task Enum_dto_with_corresponds_attribute_should_pass() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] + InProgress = 2, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + Cancelled = 4 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Enum_dto_with_value_mismatch_but_correct_corresponds_should_pass() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + Cancelled = 2 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Enum_dto_with_multiple_corresponds_values_should_pass() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 1, + Cancelled = 3 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Enum_dto_with_ignore_enum_dto_attribute_should_be_ignored() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +[LeanCode.CodeAnalysis.IgnoreEnumDto] +public enum StatusDTO +{ + // This enum is completely different and should be ignored + Alpha = 100, + Beta = 200 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Non_dto_enum_should_be_ignored() + { + var source = + @" +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum Priority +{ + Low = 1, + Medium = 2, + High = 3 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Enum_dto_without_corresponding_base_enum_should_be_ignored() + { + var source = + @" +namespace Test; + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +}"; + + await VerifyDiagnostics(source); + } + + protected override DiagnosticAnalyzer GetDiagnosticAnalyzer() + { + return new EnumDtoConsistencyAnalyzer(); + } +} diff --git a/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs b/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs new file mode 100644 index 000000000..51ed29f28 --- /dev/null +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs @@ -0,0 +1,346 @@ +using LeanCode.CodeAnalysis.Analyzers; +using LeanCode.CodeAnalysis.CodeFixProviders; +using LeanCode.CodeAnalysis.Tests.Verifiers; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace LeanCode.CodeAnalysis.Tests.CodeActions; + +public class SynchronizeEnumDtoCodeActionTests : CodeFixVerifier +{ + private const string AttributeDefinitions = + @" +using System; + +namespace LeanCode.CodeAnalysis +{ + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumDtoAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class ExcludeMembersAttribute : Attribute + { + public object[] IgnoredValues { get; } + public ExcludeMembersAttribute(params object[] ignoredValues) + { + IgnoredValues = ignoredValues; + } + } + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumValueAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class EnumValueCorrespondsAttribute : Attribute + { + public object[] CorrespondingValues { get; } + public EnumValueCorrespondsAttribute(params object[] correspondingValues) + { + CorrespondingValues = correspondingValues; + } + } +} +"; + + [Fact] + public async Task Synchronizes_enum_dto_missing_members() + { + var source = + @" +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2 +}"; + + var expected = + @" +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +}"; + + var fixes = new[] { "Synchronize DTO enum with base enum" }; + await VerifyCodeFix(source, expected, fixes, 0); + } + + [Fact] + public async Task Synchronizes_enum_dto_with_value_mismatches() + { + var source = + @" +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 6, + Completed = 7, + Cancelled = 8 +}"; + + var expected = + @" +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +}"; + + var fixes = new[] { "Synchronize DTO enum with base enum" }; + await VerifyCodeFix(source, expected, fixes, 0); + } + + [Fact] + public async Task Removes_extra_members_but_keeps_those_with_ignore_attribute() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + ExtraWithoutAttribute = 4, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + ExtraWithAttribute = 5 +}"; + + var expected = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + ExtraWithAttribute = 5 +}"; + + var fixes = new[] { "Synchronize DTO enum with base enum" }; + await VerifyCodeFix(source, expected, fixes, 0); + } + + [Fact] + public async Task Keeps_members_with_corresponds_attribute() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +public enum StatusDTO +{ + None = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] + InProgress = 2, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + Cancelled = 4 +}"; + + await VerifyDiagnostics(source); + } + + [Fact] + public async Task Respects_exclude_members_attribute() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +[LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] +public enum StatusDTO +{ + WrongMember = 999, + Completed = 2, + Cancelled = 3 +}"; + + var expected = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 +} + +[LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] +public enum StatusDTO +{ + Completed = 2, + Cancelled = 3 +}"; + + var fixes = new[] { "Synchronize DTO enum with base enum" }; + await VerifyCodeFix(source, expected, fixes, 0); + } + + [Fact] + public async Task Handles_complex_scenario_with_multiple_attributes() + { + var source = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + Archived = 4 +} + +[LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] +public enum StatusDTO +{ + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 1, + Cancelled = 999, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + CustomStatus = 100 +}"; + + var expected = + AttributeDefinitions + + @" + +namespace Test; + +public enum Status +{ + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + Archived = 4 +} + +[LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] +public enum StatusDTO +{ + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 1, + Cancelled = 3, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + CustomStatus = 100 +}"; + + var fixes = new[] { "Synchronize DTO enum with base enum" }; + await VerifyCodeFix(source, expected, fixes, 0); + } + + protected override CodeFixProvider GetCodeFixProvider() + { + return new SynchronizeEnumDtoCodeFixProvider(); + } + + protected override DiagnosticAnalyzer GetDiagnosticAnalyzer() + { + return new EnumDtoConsistencyAnalyzer(); + } +} From 0ef75d85d0547ab58d95a40a180e2146a7d369d1 Mon Sep 17 00:00:00 2001 From: Piotr Kryczka Date: Thu, 25 Sep 2025 12:04:47 +0200 Subject: [PATCH 2/5] Fix spacings --- .../EnumDtoAttributes.cs | 4 +- .../EnumDtoConsistencyAnalyzerTests.cs | 514 ++++++++--------- .../SynchronizeEnumDtoCodeActionTests.cs | 520 +++++++++--------- 3 files changed, 526 insertions(+), 512 deletions(-) diff --git a/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs b/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs index e9d6836c8..986932728 100644 --- a/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs +++ b/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs @@ -9,7 +9,7 @@ namespace LeanCode.CodeAnalysis; /// When applied, the enum will not be checked for consistency with its base enum. /// [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] -public sealed class IgnoreEnumDtoAttribute : Attribute { } +public sealed class IgnoreEnumDtoAttribute : Attribute; /// /// Attribute that specifies which enum values from the base enum should be excluded @@ -47,7 +47,7 @@ public ExcludeMembersAttribute(params object[] ignoredValues) /// When applied, the analyzer will not require a corresponding value in the base enum. /// [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] -public sealed class IgnoreEnumValueAttribute : Attribute { } +public sealed class IgnoreEnumValueAttribute : Attribute; /// /// Attribute that specifies which enum value(s) from the base enum this DTO enum value corresponds to. diff --git a/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs index 97a03b027..5049bc1db 100644 --- a/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs @@ -7,62 +7,61 @@ namespace LeanCode.CodeAnalysis.Tests.Analyzers; public class EnumDtoConsistencyAnalyzerTests : DiagnosticVerifier { - private const string AttributeDefinitions = - @" -using System; + private const string AttributeDefinitions = """ + using System; -namespace LeanCode.CodeAnalysis -{ - [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] - public sealed class IgnoreEnumDtoAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] - public sealed class ExcludeMembersAttribute : Attribute - { - public object[] IgnoredValues { get; } - public ExcludeMembersAttribute(params object[] ignoredValues) - { - IgnoredValues = ignoredValues; - } - } - - [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] - public sealed class IgnoreEnumValueAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] - public sealed class EnumValueCorrespondsAttribute : Attribute - { - public object[] CorrespondingValues { get; } - public EnumValueCorrespondsAttribute(params object[] correspondingValues) + namespace LeanCode.CodeAnalysis { - CorrespondingValues = correspondingValues; + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumDtoAttribute : Attribute; + + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class ExcludeMembersAttribute : Attribute + { + public object[] IgnoredValues { get; } + public ExcludeMembersAttribute(params object[] ignoredValues) + { + IgnoredValues = ignoredValues; + } + } + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumValueAttribute : Attribute; + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class EnumValueCorrespondsAttribute : Attribute + { + public object[] CorrespondingValues { get; } + public EnumValueCorrespondsAttribute(params object[] correspondingValues) + { + CorrespondingValues = correspondingValues; + } + } } - } -} -"; + """; [Fact] public async Task Matching_enum_dto_with_base_enum_should_pass() { - var source = - @" -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -}"; + var source = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + """; await VerifyDiagnostics(source); } @@ -70,23 +69,24 @@ public enum StatusDTO [Fact] public async Task Enum_dto_missing_members_should_fail() { - var source = - @"namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2 -}"; + var source = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2 + } + """; var diags = new[] { new DiagnosticResult(DiagnosticsIds.EnumDtoShouldMatchBaseEnum, 10, 12) }; await VerifyDiagnostics(source, diags); @@ -97,24 +97,25 @@ public async Task Enum_dto_with_exclude_members_attribute_should_pass() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -[LeanCode.CodeAnalysis.ExcludeMembers(Status.Completed, Status.Cancelled)] -public enum StatusDTO -{ - None = 0, - InProgress = 1, -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + [LeanCode.CodeAnalysis.ExcludeMembers(Status.Completed, Status.Cancelled)] + public enum StatusDTO + { + None = 0, + InProgress = 1, + } + """; await VerifyDiagnostics(source); } @@ -122,25 +123,26 @@ public enum StatusDTO [Fact] public async Task Enum_dto_with_extra_members_should_fail() { - var source = - @"namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3, - Deleted = 4 -}"; + var source = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + Deleted = 4 + } + """; var diags = new[] { new DiagnosticResult(DiagnosticsIds.EnumDtoShouldMatchBaseEnum, 10, 12) }; await VerifyDiagnostics(source, diags); @@ -151,27 +153,28 @@ public async Task Enum_dto_with_ignore_attribute_on_extra_member_should_pass() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3, - [LeanCode.CodeAnalysis.IgnoreEnumValue] - Deleted = 4 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + Deleted = 4 + } + """; await VerifyDiagnostics(source); } @@ -179,24 +182,25 @@ public enum StatusDTO [Fact] public async Task Enum_dto_with_value_mismatch_should_fail() { - var source = - @"namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 4 -}"; + var source = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 4 + } + """; var diags = new[] { new DiagnosticResult(DiagnosticsIds.EnumDtoShouldMatchBaseEnum, 10, 12) }; await VerifyDiagnostics(source, diags); @@ -207,28 +211,29 @@ public async Task Enum_dto_with_corresponds_attribute_should_pass() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] - InProgress = 2, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] - Completed = 3, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] - Cancelled = 4 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] + InProgress = 2, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + Cancelled = 4 + } + """; await VerifyDiagnostics(source); } @@ -238,27 +243,28 @@ public async Task Enum_dto_with_value_mismatch_but_correct_corresponds_should_pa { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] - Completed = 3, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] - Cancelled = 2 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + Cancelled = 2 + } + """; await VerifyDiagnostics(source); } @@ -268,26 +274,27 @@ public async Task Enum_dto_with_multiple_corresponds_values_should_pass() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] - InProgress = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] - Completed = 1, - Cancelled = 3 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 1, + Cancelled = 3 + } + """; await VerifyDiagnostics(source); } @@ -297,25 +304,26 @@ public async Task Enum_dto_with_ignore_enum_dto_attribute_should_be_ignored() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -[LeanCode.CodeAnalysis.IgnoreEnumDto] -public enum StatusDTO -{ - // This enum is completely different and should be ignored - Alpha = 100, - Beta = 200 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + [LeanCode.CodeAnalysis.IgnoreEnumDto] + public enum StatusDTO + { + // This enum is completely different and should be ignored + Alpha = 100, + Beta = 200 + } + """; await VerifyDiagnostics(source); } @@ -323,24 +331,24 @@ public enum StatusDTO [Fact] public async Task Non_dto_enum_should_be_ignored() { - var source = - @" -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum Priority -{ - Low = 1, - Medium = 2, - High = 3 -}"; + var source = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum Priority + { + Low = 1, + Medium = 2, + High = 3 + } + """; await VerifyDiagnostics(source); } @@ -348,17 +356,17 @@ public enum Priority [Fact] public async Task Enum_dto_without_corresponding_base_enum_should_be_ignored() { - var source = - @" -namespace Test; - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -}"; + var source = """ + namespace Test; + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + """; await VerifyDiagnostics(source); } diff --git a/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs b/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs index 51ed29f28..4353aac95 100644 --- a/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs @@ -9,81 +9,80 @@ namespace LeanCode.CodeAnalysis.Tests.CodeActions; public class SynchronizeEnumDtoCodeActionTests : CodeFixVerifier { - private const string AttributeDefinitions = - @" -using System; + private const string AttributeDefinitions = """ + using System; -namespace LeanCode.CodeAnalysis -{ - [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] - public sealed class IgnoreEnumDtoAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] - public sealed class ExcludeMembersAttribute : Attribute - { - public object[] IgnoredValues { get; } - public ExcludeMembersAttribute(params object[] ignoredValues) - { - IgnoredValues = ignoredValues; - } - } - - [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] - public sealed class IgnoreEnumValueAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] - public sealed class EnumValueCorrespondsAttribute : Attribute - { - public object[] CorrespondingValues { get; } - public EnumValueCorrespondsAttribute(params object[] correspondingValues) + namespace LeanCode.CodeAnalysis { - CorrespondingValues = correspondingValues; + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumDtoAttribute : Attribute; + + [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] + public sealed class ExcludeMembersAttribute : Attribute + { + public object[] IgnoredValues { get; } + public ExcludeMembersAttribute(params object[] ignoredValues) + { + IgnoredValues = ignoredValues; + } + } + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class IgnoreEnumValueAttribute : Attribute; + + [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed class EnumValueCorrespondsAttribute : Attribute + { + public object[] CorrespondingValues { get; } + public EnumValueCorrespondsAttribute(params object[] correspondingValues) + { + CorrespondingValues = correspondingValues; + } + } } - } -} -"; + """; [Fact] public async Task Synchronizes_enum_dto_missing_members() { - var source = - @" -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2 -}"; - - var expected = - @" -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -}"; + var source = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2 + } + """; + + var expected = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + """; var fixes = new[] { "Synchronize DTO enum with base enum" }; await VerifyCodeFix(source, expected, fixes, 0); @@ -92,45 +91,45 @@ public enum StatusDTO [Fact] public async Task Synchronizes_enum_dto_with_value_mismatches() { - var source = - @" -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 6, - Completed = 7, - Cancelled = 8 -}"; - - var expected = - @" -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -}"; + var source = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 6, + Completed = 7, + Cancelled = 8 + } + """; + + var expected = """ + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + """; var fixes = new[] { "Synchronize DTO enum with base enum" }; await VerifyCodeFix(source, expected, fixes, 0); @@ -141,52 +140,54 @@ public async Task Removes_extra_members_but_keeps_those_with_ignore_attribute() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3, - ExtraWithoutAttribute = 4, - [LeanCode.CodeAnalysis.IgnoreEnumValue] - ExtraWithAttribute = 5 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + ExtraWithoutAttribute = 4, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + ExtraWithAttribute = 5 + } + """; var expected = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3, - [LeanCode.CodeAnalysis.IgnoreEnumValue] - ExtraWithAttribute = 5 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + ExtraWithAttribute = 5 + } + """; var fixes = new[] { "Synchronize DTO enum with base enum" }; await VerifyCodeFix(source, expected, fixes, 0); @@ -197,28 +198,29 @@ public async Task Keeps_members_with_corresponds_attribute() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -public enum StatusDTO -{ - None = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] - InProgress = 2, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] - Completed = 3, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] - Cancelled = 4 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] + InProgress = 2, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + Cancelled = 4 + } + """; await VerifyDiagnostics(source); } @@ -228,46 +230,48 @@ public async Task Respects_exclude_members_attribute() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -[LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] -public enum StatusDTO -{ - WrongMember = 999, - Completed = 2, - Cancelled = 3 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + [LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] + public enum StatusDTO + { + WrongMember = 999, + Completed = 2, + Cancelled = 3 + } + """; var expected = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3 -} - -[LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] -public enum StatusDTO -{ - Completed = 2, - Cancelled = 3 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + [LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] + public enum StatusDTO + { + Completed = 2, + Cancelled = 3 + } + """; var fixes = new[] { "Synchronize DTO enum with base enum" }; await VerifyCodeFix(source, expected, fixes, 0); @@ -278,57 +282,59 @@ public async Task Handles_complex_scenario_with_multiple_attributes() { var source = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3, - Archived = 4 -} - -[LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] -public enum StatusDTO -{ - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] - InProgress = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] - Completed = 1, - Cancelled = 999, - [LeanCode.CodeAnalysis.IgnoreEnumValue] - CustomStatus = 100 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + Archived = 4 + } + + [LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] + public enum StatusDTO + { + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 1, + Cancelled = 999, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + CustomStatus = 100 + } + """; var expected = AttributeDefinitions - + @" - -namespace Test; - -public enum Status -{ - None = 0, - InProgress = 1, - Completed = 2, - Cancelled = 3, - Archived = 4 -} - -[LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] -public enum StatusDTO -{ - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] - InProgress = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] - Completed = 1, - Cancelled = 3, - [LeanCode.CodeAnalysis.IgnoreEnumValue] - CustomStatus = 100 -}"; + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + Archived = 4 + } + + [LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] + public enum StatusDTO + { + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + Completed = 1, + Cancelled = 3, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + CustomStatus = 100 + } + """; var fixes = new[] { "Synchronize DTO enum with base enum" }; await VerifyCodeFix(source, expected, fixes, 0); From d8e00279c4ca9e99cd1c0d4fe295d6a74d551a2e Mon Sep 17 00:00:00 2001 From: Piotr Kryczka Date: Thu, 25 Sep 2025 12:58:14 +0200 Subject: [PATCH 3/5] Use enum member values instead of names in attributes --- .../SynchronizeEnumDtoCodeAction.cs | 15 +++++++++---- .../EnumDtoAttributes.cs | 10 +++++---- .../EnumDtoConsistencyAnalyzerTests.cs | 16 +++++++------- .../SynchronizeEnumDtoCodeActionTests.cs | 22 +++++++++---------- 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs b/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs index e7ea08da5..468d0e50f 100644 --- a/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs +++ b/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs @@ -342,6 +342,10 @@ private static bool IsBaseMemberCoveredByCorrespondsAttribute( SemanticModel model ) { + var baseMemberValue = Convert + .ToInt64(baseMember.ConstantValue, CultureInfo.InvariantCulture) + .ToString(CultureInfo.InvariantCulture); + foreach (var existingMember in existingMembers.Values) { if (!HasEnumValueCorrespondsAttribute(existingMember, model)) @@ -350,7 +354,7 @@ SemanticModel model } var correspondingValues = GetCorrespondingValuesFromAttribute(existingMember, model); - if (correspondingValues.Contains(baseMember.Name)) + if (correspondingValues.Contains(baseMemberValue)) { return true; } @@ -380,10 +384,13 @@ SemanticModel model { foreach (var argument in attribute.ArgumentList.Arguments) { - if (argument.Expression is MemberAccessExpressionSyntax memberAccess) + if (argument.Expression is LiteralExpressionSyntax literal) { - var memberName = memberAccess.Name.Identifier.ValueText; - correspondingValues.Add(memberName); + if (literal.Token.IsKind(SyntaxKind.NumericLiteralToken)) + { + var value = literal.Token.ValueText; + correspondingValues.Add(value); + } } } } diff --git a/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs b/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs index 986932728..4aaf9b825 100644 --- a/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs +++ b/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs @@ -18,20 +18,21 @@ public sealed class IgnoreEnumDtoAttribute : Attribute; /// /// This attribute can only be applied to enums whose names end with "DTO". /// The excluded values are specified as parameters and correspond to values -/// from the base enum (without "DTO" suffix). +/// from the base enum (without "DTO" suffix). Only integer values are supported. /// [AttributeUsage(AttributeTargets.Enum, Inherited = false, AllowMultiple = false)] public sealed class ExcludeMembersAttribute : Attribute { /// /// The enum values from the base enum that should be excluded from consistency checking. + /// Integer values corresponding to enum members. /// public object[] IgnoredValues { get; } /// /// Initializes a new instance of the ExcludeMembersAttribute class. /// - /// The enum values to exclude from consistency checking. + /// The integer values of enum members to exclude from consistency checking. public ExcludeMembersAttribute(params object[] ignoredValues) { IgnoredValues = ignoredValues; @@ -55,20 +56,21 @@ public sealed class IgnoreEnumValueAttribute : Attribute; /// /// This attribute can only be applied to enum values within enums whose names end with "DTO". /// When applied, the analyzer will check that this DTO enum value matches the specified base enum value(s) -/// instead of requiring a matching name and value. +/// instead of requiring a matching name and value. Only integer values are supported. /// [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] public sealed class EnumValueCorrespondsAttribute : Attribute { /// /// The enum values from the base enum that this DTO enum value corresponds to. + /// Integer values corresponding to enum members. /// public object[] CorrespondingValues { get; } /// /// Initializes a new instance of the EnumValueCorrespondsAttribute class. /// - /// The base enum values this DTO enum value corresponds to. + /// The integer values of base enum members this DTO enum value corresponds to. public EnumValueCorrespondsAttribute(params object[] correspondingValues) { CorrespondingValues = correspondingValues; diff --git a/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs index 5049bc1db..dcc3622c4 100644 --- a/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs @@ -109,7 +109,7 @@ public enum Status Cancelled = 3 } - [LeanCode.CodeAnalysis.ExcludeMembers(Status.Completed, Status.Cancelled)] + [LeanCode.CodeAnalysis.ExcludeMembers(2, 3)] public enum StatusDTO { None = 0, @@ -226,11 +226,11 @@ public enum Status public enum StatusDTO { None = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(1)] InProgress = 2, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] Completed = 3, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(3)] Cancelled = 4 } """; @@ -259,9 +259,9 @@ public enum StatusDTO { None = 0, InProgress = 1, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] Completed = 3, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(3)] Cancelled = 2 } """; @@ -288,9 +288,9 @@ public enum Status public enum StatusDTO { - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(0, 1)] InProgress = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] Completed = 1, Cancelled = 3 } diff --git a/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs b/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs index 4353aac95..2937af456 100644 --- a/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs @@ -213,11 +213,11 @@ public enum Status public enum StatusDTO { None = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.InProgress)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(1)] InProgress = 2, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] Completed = 3, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Cancelled)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(3)] Cancelled = 4 } """; @@ -242,7 +242,7 @@ public enum Status Cancelled = 3 } - [LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] + [LeanCode.CodeAnalysis.ExcludeMembers(0, 1)] public enum StatusDTO { WrongMember = 999, @@ -265,7 +265,7 @@ public enum Status Cancelled = 3 } - [LeanCode.CodeAnalysis.ExcludeMembers(Status.None, Status.InProgress)] + [LeanCode.CodeAnalysis.ExcludeMembers(0, 1)] public enum StatusDTO { Completed = 2, @@ -295,12 +295,12 @@ public enum Status Archived = 4 } - [LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] + [LeanCode.CodeAnalysis.ExcludeMembers(4)] public enum StatusDTO { - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(0, 1)] InProgress = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] Completed = 1, Cancelled = 999, [LeanCode.CodeAnalysis.IgnoreEnumValue] @@ -323,12 +323,12 @@ public enum Status Archived = 4 } - [LeanCode.CodeAnalysis.ExcludeMembers(Status.Archived)] + [LeanCode.CodeAnalysis.ExcludeMembers(4)] public enum StatusDTO { - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.None, Status.InProgress)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(0, 1)] InProgress = 0, - [LeanCode.CodeAnalysis.EnumValueCorresponds(Status.Completed)] + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] Completed = 1, Cancelled = 3, [LeanCode.CodeAnalysis.IgnoreEnumValue] From 11f4c1491bd560b6fdaa2a5e1b354d3e8686951e Mon Sep 17 00:00:00 2001 From: Piotr Kryczka Date: Thu, 25 Sep 2025 13:08:56 +0200 Subject: [PATCH 4/5] Simplify AreEnumValuesEqual --- .../Analyzers/EnumDtoConsistencyAnalyzer.cs | 44 +++++++++---------- .../SynchronizeEnumDtoCodeAction.cs | 41 ++++++++--------- 2 files changed, 40 insertions(+), 45 deletions(-) diff --git a/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs b/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs index e81772a21..d8b16ea14 100644 --- a/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs +++ b/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs @@ -296,39 +296,37 @@ private static List GetCorrespondingEnumNames(AttributeData correspondsA private static bool AreEnumValuesEqual(object? value1, object? value2) { - if (value1 == null && value2 == null) + if (value1 is null && value2 is null) { return true; } - if (value1 == null || value2 == null) + if (value1 is null || value2 is null) { return false; } - // This handles cases where enums have different underlying types (byte, short, int, etc.) - if (value1.GetType() != value2.GetType()) + var type1 = value1.GetType(); + var type2 = value2.GetType(); + + if (!type1.IsEnum || !type2.IsEnum) { - try - { - var converted1 = Convert.ToInt64(value1, CultureInfo.InvariantCulture); - var converted2 = Convert.ToInt64(value2, CultureInfo.InvariantCulture); - return converted1 == converted2; - } - catch (FormatException) - { - return false; - } - catch (OverflowException) - { - return false; - } - catch (InvalidCastException) - { - return false; - } + return value1.Equals(value2); } - return value1.Equals(value2); + try + { + var underlyingValue1 = Convert.ToInt64(value1, CultureInfo.InvariantCulture); + var underlyingValue2 = Convert.ToInt64(value2, CultureInfo.InvariantCulture); + return underlyingValue1 == underlyingValue2; + } + catch (OverflowException) + { + return false; + } + catch (InvalidCastException) + { + return false; + } } private sealed class EnumConsistencyAnalysis diff --git a/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs b/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs index 468d0e50f..a9f76e71e 100644 --- a/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs +++ b/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs @@ -243,36 +243,33 @@ private static bool AreEnumValuesEqual(object? value1, object? value2) { return true; } - if (value1 is null || value2 is null) { return false; } - // This handles cases where enums have different underlying types (byte, short, int, etc.) - if (value1.GetType() != value2.GetType()) + var type1 = value1.GetType(); + var type2 = value2.GetType(); + + if (!type1.IsEnum || !type2.IsEnum) { - try - { - var converted1 = Convert.ToInt64(value1, CultureInfo.InvariantCulture); - var converted2 = Convert.ToInt64(value2, CultureInfo.InvariantCulture); - return converted1 == converted2; - } - catch (FormatException) - { - return false; - } - catch (OverflowException) - { - return false; - } - catch (InvalidCastException) - { - return false; - } + return value1.Equals(value2); } - return value1.Equals(value2); + try + { + var underlyingValue1 = Convert.ToInt64(value1, CultureInfo.InvariantCulture); + var underlyingValue2 = Convert.ToInt64(value2, CultureInfo.InvariantCulture); + return underlyingValue1 == underlyingValue2; + } + catch (OverflowException) + { + return false; + } + catch (InvalidCastException) + { + return false; + } } private static long GetEnumMemberValue(EnumMemberDeclarationSyntax member) From 4c853588a48d9e8dbdc0ee011695cf29415dce42 Mon Sep 17 00:00:00 2001 From: Piotr Kryczka Date: Thu, 25 Sep 2025 14:26:34 +0200 Subject: [PATCH 5/5] Add test for duplicated enum members --- .../EnumDtoConsistencyAnalyzerTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs index dcc3622c4..ce5caa44d 100644 --- a/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs @@ -371,6 +371,37 @@ public enum StatusDTO await VerifyDiagnostics(source); } + [Fact] + public async Task Enum_dto_with_duplicated_member_should_pass() + { + var source = + AttributeDefinitions + + """ + + namespace Test; + + public enum Status + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3 + } + + public enum StatusDTO + { + None = 0, + InProgress = 1, + Completed = 2, + Cancelled = 3, + [LeanCode.CodeAnalysis.IgnoreEnumValue] + CancelledDuplicated = Cancelled + } + """; + + await VerifyDiagnostics(source); + } + protected override DiagnosticAnalyzer GetDiagnosticAnalyzer() { return new EnumDtoConsistencyAnalyzer();