diff --git a/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs b/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs new file mode 100644 index 000000000..d8b16ea14 --- /dev/null +++ b/src/Tools/LeanCode.CodeAnalysis/Analyzers/EnumDtoConsistencyAnalyzer.cs @@ -0,0 +1,340 @@ +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 is null && value2 is null) + { + return true; + } + if (value1 is null || value2 is null) + { + return false; + } + + var type1 = value1.GetType(); + var type2 = value2.GetType(); + + if (!type1.IsEnum || !type2.IsEnum) + { + 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 + { + 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..a9f76e71e --- /dev/null +++ b/src/Tools/LeanCode.CodeAnalysis/CodeActions/SynchronizeEnumDtoCodeAction.cs @@ -0,0 +1,401 @@ +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; + } + + var type1 = value1.GetType(); + var type2 = value2.GetType(); + + if (!type1.IsEnum || !type2.IsEnum) + { + 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) + { + 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 + ) + { + var baseMemberValue = Convert + .ToInt64(baseMember.ConstantValue, CultureInfo.InvariantCulture) + .ToString(CultureInfo.InvariantCulture); + + foreach (var existingMember in existingMembers.Values) + { + if (!HasEnumValueCorrespondsAttribute(existingMember, model)) + { + continue; + } + + var correspondingValues = GetCorrespondingValuesFromAttribute(existingMember, model); + if (correspondingValues.Contains(baseMemberValue)) + { + 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 LiteralExpressionSyntax literal) + { + if (literal.Token.IsKind(SyntaxKind.NumericLiteralToken)) + { + var value = literal.Token.ValueText; + correspondingValues.Add(value); + } + } + } + } + } + } + } + } + + 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..4aaf9b825 --- /dev/null +++ b/src/Tools/LeanCode.CodeAnalysis/EnumDtoAttributes.cs @@ -0,0 +1,78 @@ +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). 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 integer values of enum members 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. 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 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 new file mode 100644 index 000000000..ce5caa44d --- /dev/null +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/Analyzers/EnumDtoConsistencyAnalyzerTests.cs @@ -0,0 +1,409 @@ +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(2, 3)] + 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(1)] + InProgress = 2, + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(3)] + 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(2)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(3)] + 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(0, 1)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] + 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); + } + + [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(); + } +} 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..2937af456 --- /dev/null +++ b/test/Tools/LeanCode.CodeAnalysis.Tests/CodeActions/SynchronizeEnumDtoCodeActionTests.cs @@ -0,0 +1,352 @@ +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(1)] + InProgress = 2, + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] + Completed = 3, + [LeanCode.CodeAnalysis.EnumValueCorresponds(3)] + 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(0, 1)] + 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(0, 1)] + 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(4)] + public enum StatusDTO + { + [LeanCode.CodeAnalysis.EnumValueCorresponds(0, 1)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] + 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(4)] + public enum StatusDTO + { + [LeanCode.CodeAnalysis.EnumValueCorresponds(0, 1)] + InProgress = 0, + [LeanCode.CodeAnalysis.EnumValueCorresponds(2)] + 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(); + } +}