Skip to content

Commit f994d8e

Browse files
committed
DefinedEnum<TEnum> for validated enums.
1 parent 5b9eb81 commit f994d8e

29 files changed

+2538
-216
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using Microsoft.CodeAnalysis;
2+
3+
namespace Architect.DomainModeling.Analyzer;
4+
5+
internal static class AnalyzerTypeSymbolExtensions
6+
{
7+
public static bool IsNullable(this ITypeSymbol? potentialNullable, out ITypeSymbol nullableUnderlyingType)
8+
{
9+
if (potentialNullable is not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol)
10+
{
11+
nullableUnderlyingType = null!;
12+
return false;
13+
}
14+
15+
nullableUnderlyingType = namedTypeSymbol.TypeArguments[0];
16+
return true;
17+
}
18+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace Architect.DomainModeling.Analyzer.Analyzers;
8+
9+
/// <summary>
10+
/// Enforces the use of DefinedEnum&lt;TEnum, TPrimitive&gt; over DefinedEnum&lt;TEnum&gt; in properties and fields.
11+
/// </summary>
12+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
13+
public sealed class DefinedEnumMemberWithoutPrimitiveAnalyzer : DiagnosticAnalyzer
14+
{
15+
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
16+
[System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")]
17+
private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor(
18+
id: "DefinedEnumMemberMissingPrimitiveSpecification",
19+
title: "DefinedEnum member missing specification of primitive representation",
20+
messageFormat: "DefinedEnum member {0}.{1} must specify its primitive representation using the second generic type parameter",
21+
category: "Design",
22+
defaultSeverity: DiagnosticSeverity.Error,
23+
isEnabledByDefault: true);
24+
25+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];
26+
27+
public override void Initialize(AnalysisContext context)
28+
{
29+
context.EnableConcurrentExecution();
30+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics);
31+
32+
context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, SyntaxKind.PropertyDeclaration);
33+
context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration);
34+
}
35+
36+
private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context)
37+
{
38+
var propertySyntax = (PropertyDeclarationSyntax)context.Node;
39+
var property = context.SemanticModel.GetDeclaredSymbol(propertySyntax);
40+
41+
if (property is null)
42+
return;
43+
44+
WarnAgainstMissingPrimitiveSpecification(context, property.Type, property.ContainingType, propertySyntax.Type);
45+
}
46+
47+
private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context)
48+
{
49+
var fieldSyntax = (FieldDeclarationSyntax)context.Node;
50+
51+
// Note that fields can be defined like this:
52+
// private int field1, field2, field3;
53+
if (fieldSyntax.Declaration.Variables.Count == 0) // Prevents a NullReferenceException when enumerating the variables
54+
return;
55+
foreach (var fieldVariableSyntax in fieldSyntax.Declaration.Variables)
56+
{
57+
if (context.SemanticModel.GetDeclaredSymbol(fieldVariableSyntax) is not IFieldSymbol field)
58+
continue;
59+
60+
WarnAgainstMissingPrimitiveSpecification(context, field.Type, field.ContainingType, fieldSyntax.Declaration.Type);
61+
}
62+
}
63+
64+
private static void WarnAgainstMissingPrimitiveSpecification(SyntaxNodeAnalysisContext context, ITypeSymbol typeSymbol, ITypeSymbol containingType, SyntaxNode locationNode)
65+
{
66+
// Dig through nullable
67+
if (typeSymbol.IsNullable(out var nullableUnderlyingType))
68+
typeSymbol = nullableUnderlyingType;
69+
70+
if (typeSymbol is not
71+
INamedTypeSymbol
72+
{
73+
IsGenericType: true, Arity: 1, Name: "DefinedEnum",
74+
ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } }
75+
})
76+
return;
77+
78+
var diagnostic = Diagnostic.Create(
79+
DiagnosticDescriptor,
80+
locationNode.GetLocation(),
81+
containingType.Name,
82+
typeSymbol.Name);
83+
84+
context.ReportDiagnostic(diagnostic);
85+
}
86+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.Diagnostics;
4+
using Microsoft.CodeAnalysis.Operations;
5+
6+
namespace Architect.DomainModeling.Analyzer.Analyzers;
7+
8+
/// <summary>
9+
/// Prevents attempts to implicilty convert from a non-constant TEnum value to DefinedEnum&lt;TEnum&gt; or DefinedEnum&lt;TEnum, TPrimitive&gt;.
10+
/// </summary>
11+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
12+
public sealed class ImplicitDefinedEnumConversionFromNonConstantAnalyzer : DiagnosticAnalyzer
13+
{
14+
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
15+
[System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")]
16+
private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor(
17+
id: "ImplicitConversionFromUnvalidatedEnumToDefinedEnum",
18+
title: "Implicit conversion from unvalidated enum to DefinedEnum",
19+
messageFormat: "Only a defined enum constant may be implicitly converted to DefinedEnum. For non-constant values, use DefinedEnum.Create(), a constructor, or an explicit conversion.",
20+
category: "Usage",
21+
defaultSeverity: DiagnosticSeverity.Error,
22+
isEnabledByDefault: true);
23+
24+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];
25+
26+
public override void Initialize(AnalysisContext context)
27+
{
28+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics);
29+
context.EnableConcurrentExecution();
30+
31+
context.RegisterOperationAction(AnalyzeConversion, OperationKind.Conversion);
32+
}
33+
34+
private static void AnalyzeConversion(OperationAnalysisContext context)
35+
{
36+
var conversion = (IConversionOperation)context.Operation;
37+
38+
// Only implicit conversions are relevant to us
39+
if (!conversion.IsImplicit)
40+
return;
41+
42+
var from = conversion.Operand.Type;
43+
var to = conversion.Type;
44+
45+
// Dig through nullables
46+
if (from.IsNullable(out var nullableUnderlyingType))
47+
from = nullableUnderlyingType;
48+
if (to.IsNullable(out nullableUnderlyingType))
49+
to = nullableUnderlyingType;
50+
51+
// Only from enum is relevant to us
52+
if (from is not { TypeKind: TypeKind.Enum } enumType)
53+
return;
54+
55+
// Only to DefinedEnum is relevant to us
56+
if (to is not
57+
INamedTypeSymbol
58+
{
59+
IsGenericType: true, Arity: 1 or 2, Name: "DefinedEnum",
60+
ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } }
61+
})
62+
return;
63+
64+
// Produce an error if the implicit conversion is not coming from a defined constant value of the enum's type
65+
if (!IsDefinedEnumConstant(enumType, conversion.Operand.ConstantValue))
66+
{
67+
var diagnostic = Diagnostic.Create(
68+
DiagnosticDescriptor,
69+
conversion.Syntax.GetLocation());
70+
71+
context.ReportDiagnostic(diagnostic);
72+
}
73+
}
74+
75+
private static bool IsDefinedEnumConstant(ITypeSymbol enumType, Optional<object?> constantValue)
76+
{
77+
if (!constantValue.HasValue)
78+
return false;
79+
80+
if (enumType is not INamedTypeSymbol { EnumUnderlyingType: { } } namedEnumType)
81+
return false;
82+
83+
var binaryValue = GetBinaryValue(namedEnumType.EnumUnderlyingType, constantValue.Value);
84+
85+
var valueIsDefined = namedEnumType.GetMembers().Any(member =>
86+
member is IFieldSymbol { ConstantValue: var value } && GetBinaryValue(namedEnumType.EnumUnderlyingType, value) == binaryValue);
87+
88+
return valueIsDefined;
89+
}
90+
91+
private static ulong? GetBinaryValue(ITypeSymbol enumUnderlyingType, object? value)
92+
{
93+
if (value is null)
94+
return null;
95+
96+
return (enumUnderlyingType.SpecialType, Type.GetTypeCode(value.GetType())) switch
97+
{
98+
(SpecialType.System_Byte, TypeCode.Byte) => Convert.ToByte(value),
99+
(SpecialType.System_SByte, TypeCode.SByte) => (ulong)Convert.ToSByte(value),
100+
(SpecialType.System_UInt16, TypeCode.UInt16) => Convert.ToUInt16(value),
101+
(SpecialType.System_Int16, TypeCode.Int16) => (ulong)Convert.ToInt16(value),
102+
(SpecialType.System_UInt32, TypeCode.UInt32) => Convert.ToUInt32(value),
103+
(SpecialType.System_Int32, TypeCode.Int32) => (ulong)Convert.ToInt32(value),
104+
(SpecialType.System_UInt64, TypeCode.UInt64) => Convert.ToUInt64(value),
105+
(SpecialType.System_Int64, TypeCode.Int64) => (ulong)Convert.ToInt64(value),
106+
_ => null,
107+
};
108+
}
109+
}

DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,20 @@ private static void AnalyzeBinaryExpression(SyntaxNodeAnalysisContext context)
5555
return;
5656

5757
// If either operand was implicitly converted FROM some IValueObject to something else, then the comparison is ill-advised
58-
if (OperandWasImplicitlyConvertedFromIValueObject(leftTypeInfo) || OperandWasImplicitlyConvertedFromIValueObject(rightTypeInfo))
58+
if (OperandWasImplicitlyConvertedFromSomeIValueObject(leftTypeInfo) || OperandWasImplicitlyConvertedFromSomeIValueObject(rightTypeInfo))
5959
{
6060
var diagnostic = Diagnostic.Create(
6161
DiagnosticDescriptor,
6262
context.Node.GetLocation(),
6363
binaryExpression.OperatorToken.ValueText,
64-
IsNullable(leftTypeInfo.Type, out var nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : leftTypeInfo.Type.Name,
65-
IsNullable(rightTypeInfo.Type, out nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : rightTypeInfo.Type.Name);
64+
leftTypeInfo.Type.IsNullable(out var nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : leftTypeInfo.Type.Name,
65+
rightTypeInfo.Type.IsNullable(out nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : rightTypeInfo.Type.Name);
6666

6767
context.ReportDiagnostic(diagnostic);
6868
}
6969
}
7070

71-
private static bool OperandWasImplicitlyConvertedFromIValueObject(TypeInfo operandTypeInfo)
71+
private static bool OperandWasImplicitlyConvertedFromSomeIValueObject(TypeInfo operandTypeInfo)
7272
{
7373
var from = operandTypeInfo.Type;
7474
var to = operandTypeInfo.ConvertedType;
@@ -79,34 +79,22 @@ private static bool OperandWasImplicitlyConvertedFromIValueObject(TypeInfo opera
7979

8080
// Do not flag nullable lifting (where a nullable and a non-nullable are compared)
8181
// Note that it LOOKS as though the nullable is converted to non-nullable, but the opposite is true
82-
if (IsNullable(to, out var nullableUnderlyingType) && nullableUnderlyingType.Equals(from, SymbolEqualityComparer.Default))
82+
if (to.IsNullable(out var nullableUnderlyingType) && nullableUnderlyingType.Equals(from, SymbolEqualityComparer.Default))
8383
return false;
8484

8585
// Dig through nullables
86-
if (IsNullable(from, out nullableUnderlyingType))
86+
if (from.IsNullable(out nullableUnderlyingType))
8787
from = nullableUnderlyingType;
88-
if (IsNullable(to, out nullableUnderlyingType))
88+
if (to.IsNullable(out nullableUnderlyingType))
8989
to = nullableUnderlyingType;
9090

9191
// Backwards compatibility: If converting to ValueObject, then ignore, because the ValueObject base class implements ==(ValueObject, ValueObject)
9292
if (to is { Name: "ValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } })
9393
return false;
9494

95-
var isConvertedFromIValueObject = from.AllInterfaces.Any(interf =>
95+
var isConvertedFromSomeIValueObject = from.AllInterfaces.Any(interf =>
9696
interf is { Name: "IValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } });
9797

98-
return isConvertedFromIValueObject;
99-
}
100-
101-
private static bool IsNullable(ITypeSymbol? potentialNullable, out ITypeSymbol underlyingType)
102-
{
103-
if (potentialNullable is not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol)
104-
{
105-
underlyingType = null!;
106-
return false;
107-
}
108-
109-
underlyingType = namedTypeSymbol.TypeArguments[0];
110-
return true;
98+
return isConvertedFromSomeIValueObject;
11199
}
112100
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace Architect.DomainModeling.Analyzer.Analyzers;
8+
9+
/// <summary>
10+
/// Prevents the use of comparison operators with nullables, where lifting causes nulls to be handled without being treated as less than any other value.
11+
/// This avoids a counterintuitive and likely unintended result for comparisons between null and non-null.
12+
/// </summary>
13+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
14+
public sealed class ValueObjectLiftingOnComparisonOperatorAnalyzer : DiagnosticAnalyzer
15+
{
16+
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
17+
[System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")]
18+
private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor(
19+
id: "CounterintuitiveNullHandlingOnLiftedValueObjectComparison",
20+
title: "Comparisons between null and non-null might produce unintended results",
21+
messageFormat: "'Lifted' comparisons do not treat null as less than other values, which may lead to unexpected results. Handle nulls explicitly, or use Comparer<T>.Default.Compare() to treat null as smaller than other values.",
22+
category: "Usage",
23+
defaultSeverity: DiagnosticSeverity.Warning,
24+
isEnabledByDefault: true);
25+
26+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];
27+
28+
public override void Initialize(AnalysisContext context)
29+
{
30+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics);
31+
context.EnableConcurrentExecution();
32+
33+
context.RegisterSyntaxNodeAction(
34+
AnalyzeBinaryExpression,
35+
SyntaxKind.LessThanExpression,
36+
SyntaxKind.LessThanOrEqualExpression,
37+
SyntaxKind.GreaterThanExpression,
38+
SyntaxKind.GreaterThanOrEqualExpression);
39+
}
40+
41+
private static void AnalyzeBinaryExpression(SyntaxNodeAnalysisContext context)
42+
{
43+
if (context.Node is not BinaryExpressionSyntax binaryExpression)
44+
return;
45+
46+
var semanticModel = context.SemanticModel;
47+
var cancellationToken = context.CancellationToken;
48+
49+
var leftTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Left, cancellationToken);
50+
var rightTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Right, cancellationToken);
51+
52+
// If either operand is a nullable of some IValueObject, then the comparison is ill-advised
53+
if (OperandIsSomeNullableIValueObject(leftTypeInfo) || OperandIsSomeNullableIValueObject(rightTypeInfo))
54+
{
55+
var diagnostic = Diagnostic.Create(
56+
DiagnosticDescriptor,
57+
context.Node.GetLocation());
58+
59+
context.ReportDiagnostic(diagnostic);
60+
}
61+
}
62+
63+
private static bool OperandIsSomeNullableIValueObject(TypeInfo operandTypeInfo)
64+
{
65+
var type = operandTypeInfo.ConvertedType;
66+
67+
// Note that, for nullables, it can LOOK as if non-nullables are compared, but that is not the case
68+
if (!type.IsNullable(out var nullableUnderlyingType))
69+
return false;
70+
71+
var isSomeIValueObject = nullableUnderlyingType.AllInterfaces.Any(interf =>
72+
interf is { Name: "IValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } });
73+
74+
return isSomeIValueObject;
75+
}
76+
}

0 commit comments

Comments
 (0)