Skip to content

Commit 1ca130d

Browse files
committed
Generated [Wrapper]ValueObjects can now be [structs and/or] records and/or use a custom base class.
1 parent 9b1f809 commit 1ca130d

36 files changed

+1678
-770
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
// This is a separate analyzer because diagnostics directly from source generators appear less reliably, and this is an important diagnostic
10+
11+
/// <summary>
12+
/// Enforces a StringComparison property on annotated, partial ValueObject types with string members.
13+
/// </summary>
14+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
15+
public sealed class ValueObjectMissingStringComparisonAnalyzer : DiagnosticAnalyzer
16+
{
17+
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
18+
[System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")]
19+
private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor(
20+
id: "ValueObjectGeneratorMissingStringComparison",
21+
title: "ValueObject has string members but no StringComparison property",
22+
messageFormat: "ValueObject {0} has string members but no StringComparison property to know how to compare them. Either wrap string members in dedicated WrapperValueObjects, or implement 'private StringComparison StringComparison => ...",
23+
category: "Design",
24+
defaultSeverity: DiagnosticSeverity.Warning,
25+
isEnabledByDefault: true);
26+
27+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];
28+
29+
public override void Initialize(AnalysisContext context)
30+
{
31+
context.EnableConcurrentExecution();
32+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
33+
34+
context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration,
35+
SyntaxKind.ClassDeclaration,
36+
SyntaxKind.RecordDeclaration);
37+
}
38+
39+
private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context)
40+
{
41+
var tds = (TypeDeclarationSyntax)context.Node;
42+
43+
// Only partial
44+
if (!tds.Modifiers.Any(SyntaxKind.PartialKeyword))
45+
return;
46+
47+
var semanticModel = context.SemanticModel;
48+
var type = semanticModel.GetDeclaredSymbol(tds, context.CancellationToken);
49+
50+
if (type is null)
51+
return;
52+
53+
// Only with ValueObjectAttribute
54+
if (!type.GetAttributes().Any(attr => attr.AttributeClass is { Name: "ValueObjectAttribute", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } }, }))
55+
return;
56+
57+
// Only with string fields
58+
if (!type.GetMembers().Any(member => member is IFieldSymbol { Type.SpecialType: SpecialType.System_String }))
59+
return;
60+
61+
// Only without StringComparison property (hand-written)
62+
if (type.GetMembers("StringComparison").Any(member => member is IPropertySymbol { IsImplicitlyDeclared: false } prop &&
63+
prop.DeclaringSyntaxReferences.Length > 0 && prop.DeclaringSyntaxReferences[0].SyntaxTree.FilePath?.EndsWith(".g.cs") == false))
64+
return;
65+
66+
var diagnostic = Diagnostic.Create(
67+
DiagnosticDescriptor,
68+
tds.Identifier.GetLocation(),
69+
type.Name);
70+
71+
context.ReportDiagnostic(diagnostic);
72+
}
73+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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 default expressions and literals on struct WrapperValueObject types, so that validation cannot be circumvented.
11+
/// </summary>
12+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
13+
public sealed class WrapperValueObjectDefaultExpressionAnalyzer : 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: "WrapperValueObjectDefaultExpression",
19+
title: "Default expression instantiating unvalidated value object",
20+
messageFormat: "A 'default' expression would create an unvalidated instance of value object {0}. Use a parameterized constructor, or use IsDefault() to merely compare.",
21+
category: "Design",
22+
defaultSeverity: DiagnosticSeverity.Warning,
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(AnalyzeDefaultExpressionOrLiteral,
33+
SyntaxKind.DefaultExpression,
34+
SyntaxKind.DefaultLiteralExpression);
35+
}
36+
37+
private static void AnalyzeDefaultExpressionOrLiteral(SyntaxNodeAnalysisContext context)
38+
{
39+
var defaultExpressionOrLiteral = (ExpressionSyntax)context.Node;
40+
41+
var typeInfo = context.SemanticModel.GetTypeInfo(defaultExpressionOrLiteral, context.CancellationToken);
42+
43+
if (typeInfo.Type is not { } type)
44+
return;
45+
46+
// Only for structs
47+
if (!type.IsValueType)
48+
return;
49+
50+
// Only with WrapperValueObjectAttribute<TValue>
51+
if (!type.GetAttributes().Any(attr =>
52+
attr.AttributeClass is { Arity: 1, Name: "WrapperValueObjectAttribute", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } }, }))
53+
return;
54+
55+
var diagnostic = Diagnostic.Create(
56+
DiagnosticDescriptor,
57+
context.Node.GetLocation(),
58+
type.Name);
59+
60+
context.ReportDiagnostic(diagnostic);
61+
}
62+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
// This is a separate analyzer because diagnostics directly from source generators appear less reliably, and this is an important diagnostic
10+
11+
/// <summary>
12+
/// Enforces a StringComparison property on annotated, partial WrapperValueObject types with string members.
13+
/// </summary>
14+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
15+
public sealed class WrapperValueObjectMissingStringComparisonAnalyzer : DiagnosticAnalyzer
16+
{
17+
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
18+
[System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")]
19+
private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor(
20+
id: "WrapperValueObjectGeneratorMissingStringComparison",
21+
title: "WrapperValueObject has string members but no StringComparison property",
22+
messageFormat: "WrapperValueObject {0} has string members but no StringComparison property to know how to compare them. Implement 'private StringComparison StringComparison => ...",
23+
category: "Design",
24+
defaultSeverity: DiagnosticSeverity.Warning,
25+
isEnabledByDefault: true);
26+
27+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];
28+
29+
public override void Initialize(AnalysisContext context)
30+
{
31+
context.EnableConcurrentExecution();
32+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
33+
34+
context.RegisterSyntaxNodeAction(AnalyzeTypeDeclaration,
35+
SyntaxKind.ClassDeclaration,
36+
SyntaxKind.StructDeclaration,
37+
SyntaxKind.RecordDeclaration,
38+
SyntaxKind.RecordStructDeclaration);
39+
}
40+
41+
private static void AnalyzeTypeDeclaration(SyntaxNodeAnalysisContext context)
42+
{
43+
var tds = (TypeDeclarationSyntax)context.Node;
44+
45+
// Only partial
46+
if (!tds.Modifiers.Any(SyntaxKind.PartialKeyword))
47+
return;
48+
49+
var semanticModel = context.SemanticModel;
50+
var type = semanticModel.GetDeclaredSymbol(tds, context.CancellationToken);
51+
52+
if (type is null)
53+
return;
54+
55+
// Only with WrapperValueObjectAttribute<string>
56+
if (!type.GetAttributes().Any(attr =>
57+
attr.AttributeClass is { Arity: 1, Name: "WrapperValueObjectAttribute", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } }, } attributeClass &&
58+
attributeClass.TypeArguments[0].SpecialType == SpecialType.System_String))
59+
return;
60+
61+
// Only without StringComparison property (hand-written)
62+
if (type.GetMembers("StringComparison").Any(member => member is IPropertySymbol { IsImplicitlyDeclared: false } prop &&
63+
prop.DeclaringSyntaxReferences.Length > 0 && prop.DeclaringSyntaxReferences[0].SyntaxTree.FilePath?.EndsWith(".g.cs") == false))
64+
return;
65+
66+
var diagnostic = Diagnostic.Create(
67+
DiagnosticDescriptor,
68+
tds.Identifier.GetLocation(),
69+
type.Name);
70+
71+
context.ReportDiagnostic(diagnostic);
72+
}
73+
}

DomainModeling.Analyzer/DomainModeling.Analyzer.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
</ItemGroup>
2323

2424
<ItemGroup>
25-
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="All" />
2625
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="All" />
2726
</ItemGroup>
2827

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<AssemblyName>Architect.DomainModeling.CodeFixProviders</AssemblyName>
6+
<RootNamespace>Architect.DomainModeling.CodeFixProviders</RootNamespace>
7+
<Nullable>Enable</Nullable>
8+
<ImplicitUsings>Enable</ImplicitUsings>
9+
<LangVersion>13</LangVersion>
10+
<IsPackable>False</IsPackable>
11+
<DevelopmentDependency>True</DevelopmentDependency>
12+
<EnforceExtendedAnalyzerRules>True</EnforceExtendedAnalyzerRules>
13+
</PropertyGroup>
14+
15+
<PropertyGroup>
16+
<!-- IDE0057: Slice can be simplified -->
17+
<NoWarn>IDE0057</NoWarn>
18+
</PropertyGroup>
19+
20+
<ItemGroup>
21+
<InternalsVisibleTo Include="Architect.DomainModeling.Tests" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="All" />
26+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="All" />
27+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
28+
</ItemGroup>
29+
30+
</Project>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Runtime.CompilerServices;
4+
using Microsoft.CodeAnalysis;
5+
using Microsoft.CodeAnalysis.CodeActions;
6+
using Microsoft.CodeAnalysis.CodeFixes;
7+
using Microsoft.CodeAnalysis.CSharp;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
9+
10+
namespace Architect.DomainModeling.CodeFixProviders;
11+
12+
[Shared]
13+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MissingStringComparisonCodeFixProvider))]
14+
public sealed class MissingStringComparisonCodeFixProvider : CodeFixProvider
15+
{
16+
private static readonly ImmutableArray<string> FixableDiagnosticIdConstant = ["ValueObjectGeneratorMissingStringComparison", "WrapperValueObjectGeneratorMissingStringComparison"];
17+
18+
public override ImmutableArray<string> FixableDiagnosticIds => FixableDiagnosticIdConstant;
19+
20+
public override FixAllProvider? GetFixAllProvider()
21+
{
22+
return null;
23+
}
24+
25+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
26+
{
27+
var diagnostic = context.Diagnostics.First();
28+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
29+
30+
if (root is null)
31+
return;
32+
33+
var token = root.FindToken(diagnostic.Location.SourceSpan.Start);
34+
var tds = token.Parent?.AncestorsAndSelf()
35+
.OfType<TypeDeclarationSyntax>()
36+
.FirstOrDefault();
37+
38+
if (tds is null)
39+
return;
40+
41+
var ordinalFix = CodeAction.Create(
42+
title: "Implement StringComparison { get; } with StringComparison.Ordinal",
43+
createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.Ordinal", ct),
44+
equivalenceKey: "ImplementStringComparisonOrdinalGetter");
45+
context.RegisterCodeFix(ordinalFix, context.Diagnostics.First());
46+
47+
var ordinalIgnoreCaseFix = CodeAction.Create(
48+
title: "Implement StringComparison { get; } with StringComparison.OrdinalIgnoreCase",
49+
createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.OrdinalIgnoreCase", ct),
50+
equivalenceKey: "ImplementStringComparisonOrdinalIgnoreCaseGetter");
51+
context.RegisterCodeFix(ordinalIgnoreCaseFix, context.Diagnostics.First());
52+
}
53+
54+
private static Task<Document> AddStringComparisonMemberAsync(
55+
Document document,
56+
SyntaxNode root,
57+
TypeDeclarationSyntax tds,
58+
string stringComparisonExpression,
59+
CancellationToken _)
60+
{
61+
var newlineTrivia = GetNewlineTrivia(tds);
62+
63+
var property = SyntaxFactory.PropertyDeclaration(
64+
SyntaxFactory.ParseTypeName("StringComparison"),
65+
SyntaxFactory.Identifier("StringComparison"))
66+
.AddModifiers(SyntaxFactory.Token(SyntaxKind.PrivateKeyword))
67+
.WithExpressionBody(
68+
SyntaxFactory.ArrowExpressionClause(
69+
SyntaxFactory.ParseExpression(stringComparisonExpression)))
70+
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))
71+
.WithLeadingTrivia(newlineTrivia)
72+
.WithTrailingTrivia(newlineTrivia)
73+
.WithTrailingTrivia(newlineTrivia);
74+
75+
var updatedTds = tds.WithMembers(tds.Members.Insert(0, property));
76+
var updatedRoot = root.ReplaceNode(tds, updatedTds);
77+
return Task.FromResult(document.WithSyntaxRoot(updatedRoot));
78+
}
79+
80+
private static SyntaxTrivia GetNewlineTrivia(SyntaxNode node)
81+
{
82+
var allTrivia = node.DescendantTrivia(descendIntoTrivia: true);
83+
84+
var (nCount, rnCount) = (0, 0);
85+
86+
foreach (var trivia in allTrivia)
87+
{
88+
if (!trivia.IsKind(SyntaxKind.EndOfLineTrivia))
89+
continue;
90+
91+
var length = trivia.Span.Length;
92+
var lengthIsOne = length == 1;
93+
var lengthIsTwo = length == 2;
94+
nCount += Unsafe.As<bool, int>(ref lengthIsOne);
95+
rnCount += Unsafe.As<bool, int>(ref lengthIsTwo);
96+
}
97+
98+
return rnCount > nCount
99+
? SyntaxFactory.ElasticCarriageReturnLineFeed
100+
: SyntaxFactory.ElasticLineFeed;
101+
}
102+
}

DomainModeling.Example/Description.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
using Architect.DomainModeling.Comparisons;
2+
13
namespace Architect.DomainModeling.Example;
24

35
// Use "Go To Definition" on the type to view the source-generated partial
46
// Uncomment the IComparable interface to see how the generated code changes
57
[WrapperValueObject<string>]
6-
public partial class Description //: IComparable<Description>
8+
public partial record struct Description //: IComparable<Description>
79
{
810
// For string wrappers, we must define how they are compared
9-
protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase;
11+
private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase;
1012

1113
// Any component that we define manually is omitted by the generated code
1214
// For example, we can explicitly define the Value property to have greater clarity, since it is quintessential
@@ -19,6 +21,6 @@ public Description(string value)
1921

2022
if (this.Value.Length > 255) throw new ArgumentException("Too long.");
2123

22-
if (ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense.");
24+
if (ValueObjectStringValidator.ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense.");
2325
}
2426
}

DomainModeling.Example/DomainModeling.Example.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<ProjectReference Include="..\DomainModeling\DomainModeling.csproj" />
2222
<ProjectReference Include="..\DomainModeling.Analyzer\DomainModeling.Analyzer.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2323
<ProjectReference Include="..\DomainModeling.Generator\DomainModeling.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
24+
<ProjectReference Include="..\DomainModeling.CodeFixProviders\DomainModeling.CodeFixProviders.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2425
</ItemGroup>
2526

2627
</Project>

DomainModeling.Example/Program.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ public static void Main()
8282
{
8383
Console.WriteLine("Demonstrating structural equality for collections:");
8484

85-
var abc = new CharacterSet([ 'a', 'b', 'c', ]);
86-
var abcd = new CharacterSet([ 'a', 'b', 'c', 'd', ]);
87-
var abcClone = new CharacterSet([ 'a', 'b', 'c', ]);
85+
var abc = new CharacterSet(['a', 'b', 'c',]);
86+
var abcd = new CharacterSet(['a', 'b', 'c', 'd',]);
87+
var abcClone = new CharacterSet(['a', 'b', 'c',]);
8888

8989
Console.WriteLine($"{abc == abcd}: {abc} == {abcd} (different values)");
9090
Console.WriteLine($"{abc == abcClone}: {abc} == {abcClone} (different instances, same values in collection)"); // ValueObjects have structural equality

0 commit comments

Comments
 (0)