Skip to content

Commit bb5ac81

Browse files
committed
refactor: Use SCG For enumerations
1 parent 0da94ca commit bb5ac81

File tree

17 files changed

+511
-116
lines changed

17 files changed

+511
-116
lines changed

Directory.Packages.props

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,11 @@
5757
<PackageVersion Include="Microsoft.Playwright" Version="1.58.0" />
5858
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
5959
</ItemGroup>
60-
</Project>
60+
<ItemGroup Label="Source Code Generators">
61+
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
62+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
63+
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
64+
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="5.3.0" />
65+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
66+
</ItemGroup>
67+
</Project>

LinkDotNet.Blog.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
<File Path="src/.editorconfig" />
1616
<File Path="src/Directory.Build.props" />
1717
<Project Path="src/LinkDotNet.Blog.Domain/LinkDotNet.Blog.Domain.csproj" />
18+
<Project Path="src/LinkDotNet.Blog.Generators/LinkDotNet.Blog.Generators.csproj" />
1819
<Project Path="src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj" />
1920
<Project Path="src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj" />
2021
</Folder>
2122
<Folder Name="/tests/">
2223
<File Path="tests/.editorconfig" />
2324
<File Path="tests/Directory.Build.props" />
2425
<Project Path="tests/LinkDotNet.Blog.IntegrationTests/LinkDotNet.Blog.IntegrationTests.csproj" />
26+
<Project Path="tests/LinkDotNet.Blog.Generators.Tests/LinkDotNet.Blog.Generators.Tests.csproj" />
2527
<Project Path="tests/LinkDotNet.Blog.TestUtilities/LinkDotNet.Blog.TestUtilities.csproj" />
2628
<Project Path="tests/LinkDotNet.Blog.UnitTests/LinkDotNet.Blog.UnitTests.csproj" />
2729
<Project Path="tests/LinkDotNet.Blog.UpgradeAssistant.Tests/LinkDotNet.Blog.UpgradeAssistant.Tests.csproj" />

src/LinkDotNet.Blog.Domain/Enumeration.cs

Lines changed: 0 additions & 37 deletions
This file was deleted.

src/LinkDotNet.Blog.Domain/LinkDotNet.Blog.Domain.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@
55
<RootNamespace>LinkDotNet.Blog.Domain</RootNamespace>
66
</PropertyGroup>
77

8+
<ItemGroup>
9+
<ProjectReference Include="..\LinkDotNet.Blog.Generators\LinkDotNet.Blog.Generators.csproj"
10+
OutputItemType="Analyzer"
11+
ReferenceOutputAssembly="false" />
12+
</ItemGroup>
13+
814
</Project>
Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
namespace LinkDotNet.Blog.Domain;
1+
using LinkDotNet.Blog.Generators;
22

3-
public sealed record ProficiencyLevel : Enumeration<ProficiencyLevel>
4-
{
5-
public static readonly ProficiencyLevel Familiar = new(nameof(Familiar));
6-
public static readonly ProficiencyLevel Proficient = new(nameof(Proficient));
7-
public static readonly ProficiencyLevel Expert = new(nameof(Expert));
3+
namespace LinkDotNet.Blog.Domain;
4+
5+
[Enumeration("Familiar", "Proficient", "Expert")]
6+
public sealed partial record ProficiencyLevel;
87

9-
private ProficiencyLevel(string key)
10-
: base(key)
11-
{
12-
}
13-
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using System.Collections.Immutable;
2+
using System.Linq;
3+
using System.Text;
4+
using Microsoft.CodeAnalysis;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Text;
7+
8+
namespace LinkDotNet.Blog.Generators;
9+
10+
[Generator]
11+
public sealed class EnumerationGenerator : IIncrementalGenerator
12+
{
13+
private const string FullAttributeName = "LinkDotNet.Blog.Generators.EnumerationAttribute";
14+
15+
private const string AttributeSource = """
16+
// <auto-generated/>
17+
#nullable enable
18+
19+
using System;
20+
21+
namespace LinkDotNet.Blog.Generators;
22+
23+
/// <summary>
24+
/// Marks a <c>sealed partial record</c> as a source-generated enumeration.
25+
/// The generator emits: Key property, private constructor, static readonly fields,
26+
/// All, Create, == / != operators, ToString, Match&lt;T&gt; and Match(Action).
27+
/// </summary>
28+
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
29+
internal sealed class EnumerationAttribute : Attribute
30+
{
31+
public string[] Values { get; }
32+
public EnumerationAttribute(params string[] values) => Values = values;
33+
}
34+
""";
35+
36+
public void Initialize(IncrementalGeneratorInitializationContext context)
37+
{
38+
context.RegisterPostInitializationOutput(static ctx =>
39+
ctx.AddSource("EnumerationAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)));
40+
41+
var models = context.SyntaxProvider
42+
.ForAttributeWithMetadataName(
43+
FullAttributeName,
44+
predicate: static (node, _) => node is RecordDeclarationSyntax,
45+
transform: static (ctx, _) => GetModel(ctx))
46+
.Where(static m => m is not null);
47+
48+
context.RegisterSourceOutput(models, static (spc, model) => Emit(spc, model!));
49+
}
50+
51+
private static EnumerationModel? GetModel(GeneratorAttributeSyntaxContext ctx)
52+
{
53+
if (ctx.TargetSymbol is not INamedTypeSymbol type)
54+
{
55+
return null;
56+
}
57+
58+
var attr = ctx.Attributes.FirstOrDefault();
59+
if (attr is null || attr.ConstructorArguments.Length == 0)
60+
{
61+
return null;
62+
}
63+
64+
var arg = attr.ConstructorArguments[0];
65+
var rawValues = arg.Kind == TypedConstantKind.Array ? arg.Values : [arg];
66+
67+
var values = rawValues
68+
.Select(static v => v.Value as string)
69+
.Where(static v => v is not null)
70+
.Select(static v => v!)
71+
.ToImmutableArray();
72+
73+
if (values.IsEmpty)
74+
{
75+
return null;
76+
}
77+
78+
var ns = type.ContainingNamespace.IsGlobalNamespace
79+
? null
80+
: type.ContainingNamespace.ToDisplayString();
81+
82+
return new EnumerationModel(ns, type.Name, values);
83+
}
84+
85+
private static void Emit(SourceProductionContext ctx, EnumerationModel model)
86+
{
87+
var sb = new StringBuilder();
88+
var typeName = model.TypeName;
89+
var allFieldNames = string.Join(", ", model.Values);
90+
91+
sb.AppendLine("// <auto-generated/>");
92+
sb.AppendLine("#nullable enable");
93+
sb.AppendLine();
94+
sb.AppendLine("using System;");
95+
sb.AppendLine("using System.Collections.Frozen;");
96+
sb.AppendLine("using System.Linq;");
97+
sb.AppendLine();
98+
99+
if (model.Namespace is not null)
100+
{
101+
sb.AppendLine($"namespace {model.Namespace};");
102+
sb.AppendLine();
103+
}
104+
105+
sb.AppendLine($"public sealed partial record {typeName}");
106+
sb.AppendLine("{");
107+
108+
sb.AppendLine(" public string Key { get; init; } = default!;");
109+
sb.AppendLine();
110+
111+
sb.AppendLine($" private {typeName}(string key)");
112+
sb.AppendLine(" {");
113+
sb.AppendLine(" ArgumentException.ThrowIfNullOrWhiteSpace(key);");
114+
sb.AppendLine(" Key = key;");
115+
sb.AppendLine(" }");
116+
sb.AppendLine();
117+
118+
foreach (var value in model.Values)
119+
{
120+
sb.AppendLine($" public static readonly {typeName} {value} = new(\"{value}\");");
121+
}
122+
123+
sb.AppendLine();
124+
125+
sb.AppendLine($" public static FrozenSet<{typeName}> All {{ get; }} =");
126+
sb.AppendLine($" new {typeName}[] {{ {allFieldNames} }}.ToFrozenSet();");
127+
sb.AppendLine();
128+
129+
sb.AppendLine($" public static {typeName} Create(string key)");
130+
sb.AppendLine(" {");
131+
sb.AppendLine(" ArgumentException.ThrowIfNullOrWhiteSpace(key);");
132+
sb.AppendLine($" return All.SingleOrDefault(p => p.Key == key)");
133+
sb.AppendLine($" ?? throw new InvalidOperationException($\"{{key}} is not a valid value for {typeName}\");");
134+
sb.AppendLine(" }");
135+
sb.AppendLine();
136+
137+
sb.AppendLine($" public static bool operator ==({typeName}? a, string? b)");
138+
sb.AppendLine(" => a is not null && b is not null && a.Key.Equals(b, StringComparison.Ordinal);");
139+
sb.AppendLine();
140+
sb.AppendLine($" public static bool operator !=({typeName}? a, string? b) => !(a == b);");
141+
sb.AppendLine();
142+
143+
sb.AppendLine(" public override string ToString() => Key;");
144+
sb.AppendLine();
145+
146+
var funcParams = string.Join(", ", model.Values.Select(static v => $"Func<T> on{v}"));
147+
sb.AppendLine($" public T Match<T>({funcParams})");
148+
sb.AppendLine(" {");
149+
foreach (var value in model.Values)
150+
{
151+
sb.AppendLine($" if (Key == {value}.Key) return on{value}();");
152+
}
153+
154+
sb.AppendLine(" throw new InvalidOperationException($\"Unhandled enumeration value: {Key}\");");
155+
sb.AppendLine(" }");
156+
sb.AppendLine();
157+
158+
var actionParams = string.Join(", ", model.Values.Select(static v => $"Action on{v}"));
159+
sb.AppendLine($" public void Match({actionParams})");
160+
sb.AppendLine(" {");
161+
foreach (var value in model.Values)
162+
{
163+
sb.AppendLine($" if (Key == {value}.Key) {{ on{value}(); return; }}");
164+
}
165+
166+
sb.AppendLine(" throw new InvalidOperationException($\"Unhandled enumeration value: {Key}\");");
167+
sb.AppendLine(" }");
168+
169+
sb.AppendLine("}");
170+
171+
ctx.AddSource($"{typeName}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
172+
}
173+
}
174+
175+
internal sealed class EnumerationModel
176+
{
177+
public EnumerationModel(string? ns, string typeName, ImmutableArray<string> values)
178+
{
179+
Namespace = ns;
180+
TypeName = typeName;
181+
Values = values;
182+
}
183+
184+
public string? Namespace { get; }
185+
public string TypeName { get; }
186+
public ImmutableArray<string> Values { get; }
187+
}
188+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<LangVersion>preview</LangVersion>
6+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
7+
<IsPackable>false</IsPackable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
12+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
13+
</ItemGroup>
14+
15+
</Project>

src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919

2020
<ItemGroup>
2121
<ProjectReference Include="..\LinkDotNet.Blog.Domain\LinkDotNet.Blog.Domain.csproj" />
22+
<ProjectReference Include="..\LinkDotNet.Blog.Generators\LinkDotNet.Blog.Generators.csproj"
23+
OutputItemType="Analyzer"
24+
ReferenceOutputAssembly="false" />
2225
</ItemGroup>
2326

2427
</Project>
Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
1-
using LinkDotNet.Blog.Domain;
1+
using LinkDotNet.Blog.Generators;
22

33
namespace LinkDotNet.Blog.Infrastructure.Persistence;
44

5-
public sealed record PersistenceProvider : Enumeration<PersistenceProvider>
6-
{
7-
public static readonly PersistenceProvider SqlServer = new(nameof(SqlServer));
8-
public static readonly PersistenceProvider Sqlite = new(nameof(Sqlite));
9-
public static readonly PersistenceProvider RavenDb = new(nameof(RavenDb));
10-
public static readonly PersistenceProvider MySql = new(nameof(MySql));
11-
public static readonly PersistenceProvider MongoDB = new(nameof(MongoDB));
12-
public static readonly PersistenceProvider PostgreSql = new(nameof(PostgreSql));
5+
[Enumeration("SqlServer", "Sqlite", "RavenDb", "MySql", "MongoDB", "PostgreSql")]
6+
public sealed partial record PersistenceProvider;
137

14-
private PersistenceProvider(string key)
15-
: base(key)
16-
{
17-
}
18-
}
Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
1-
using LinkDotNet.Blog.Domain;
1+
using LinkDotNet.Blog.Generators;
22

33
namespace LinkDotNet.Blog.Web.Features.Services.FileUpload;
44

5-
public sealed record AuthenticationMode : Enumeration<AuthenticationMode>
6-
{
7-
public static readonly AuthenticationMode Default = new(nameof(Default));
5+
[Enumeration("Default", "ConnectionString")]
6+
public sealed partial record AuthenticationMode;
87

9-
public static readonly AuthenticationMode ConnectionString = new(nameof(ConnectionString));
10-
11-
public AuthenticationMode(string key) : base(key)
12-
{
13-
}
14-
}

0 commit comments

Comments
 (0)