Skip to content

Commit fbba2a0

Browse files
Copilotleeoades
andcommitted
feat: replace reflection in StateMachineAnalysis with source-generated trigger type registry
Co-authored-by: leeoades <2321091+leeoades@users.noreply.github.com>
1 parent b91908c commit fbba2a0

8 files changed

Lines changed: 439 additions & 71 deletions

File tree

FunctionalStateMachine.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalStateMachine.Benc
6565
EndProject
6666
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
6767
EndProject
68+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalStateMachine.Core.Generator", "src\FunctionalStateMachine.Core.Generator\FunctionalStateMachine.Core.Generator.csproj", "{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}"
69+
EndProject
6870
Global
6971
GlobalSection(SolutionConfigurationPlatforms) = preSolution
7072
Debug|Any CPU = Debug|Any CPU
@@ -231,6 +233,18 @@ Global
231233
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x64.Build.0 = Release|Any CPU
232234
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x86.ActiveCfg = Release|Any CPU
233235
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x86.Build.0 = Release|Any CPU
236+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
237+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|Any CPU.Build.0 = Debug|Any CPU
238+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x64.ActiveCfg = Debug|Any CPU
239+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x64.Build.0 = Debug|Any CPU
240+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x86.ActiveCfg = Debug|Any CPU
241+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x86.Build.0 = Debug|Any CPU
242+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|Any CPU.ActiveCfg = Release|Any CPU
243+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|Any CPU.Build.0 = Release|Any CPU
244+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x64.ActiveCfg = Release|Any CPU
245+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x64.Build.0 = Release|Any CPU
246+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x86.ActiveCfg = Release|Any CPU
247+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x86.Build.0 = Release|Any CPU
234248
EndGlobalSection
235249
GlobalSection(SolutionProperties) = preSolution
236250
HideSolutionNode = FALSE
@@ -251,5 +265,6 @@ Global
251265
{D1FA70BE-F53F-492A-83E3-A1D34267795E} = {A96AD5CA-C4AA-45C6-A86D-69F8ED944894}
252266
{E5866FE3-15DF-4362-95A6-C6F651434249} = {A96AD5CA-C4AA-45C6-A86D-69F8ED944894}
253267
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555} = {0C88DD14-F956-CE84-757C-A364CCF449FC}
268+
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
254269
EndGlobalSection
255270
EndGlobal
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<Nullable>enable</Nullable>
7+
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
8+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
9+
<NoWarn>$(NoWarn);RS1035</NoWarn>
10+
<IsPackable>false</IsPackable>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
15+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
16+
</ItemGroup>
17+
18+
</Project>
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CSharp.Syntax;
7+
8+
namespace FunctionalStateMachine.Core.Generator;
9+
10+
[Generator]
11+
public sealed class TriggerTypeGenerator : IIncrementalGenerator
12+
{
13+
private const string CreateMethodName = "Create";
14+
// StateMachine<TState, TTrigger, TData, TCommand> — the 4-param with-data variant
15+
private const string StateMachineMetadataName4 = "StateMachine`4";
16+
// StateMachine<TState, TTrigger, TCommand> — the 3-param NoData variant
17+
private const string StateMachineMetadataName3 = "StateMachine`3";
18+
private const string StateMachineNamespace = "FunctionalStateMachine.Core";
19+
20+
public void Initialize(IncrementalGeneratorInitializationContext context)
21+
{
22+
var triggerTypes = context.SyntaxProvider
23+
.CreateSyntaxProvider(
24+
static (node, _) => IsCreateInvocation(node),
25+
static (ctx, _) => GetTriggerType(ctx))
26+
.Where(static symbol => symbol is not null)
27+
.Select(static (symbol, _) => symbol!)
28+
.Collect();
29+
30+
var combined = triggerTypes.Combine(context.CompilationProvider);
31+
context.RegisterSourceOutput(combined, static (ctx, data) =>
32+
{
33+
var (triggerTypeSymbols, compilation) = data;
34+
if (triggerTypeSymbols.IsDefaultOrEmpty)
35+
{
36+
return;
37+
}
38+
39+
Generate(ctx, compilation, triggerTypeSymbols);
40+
});
41+
}
42+
43+
private static bool IsCreateInvocation(SyntaxNode node)
44+
{
45+
if (node is not InvocationExpressionSyntax invocation)
46+
{
47+
return false;
48+
}
49+
50+
return invocation.Expression is MemberAccessExpressionSyntax
51+
{
52+
Name: { Identifier.ValueText: CreateMethodName }
53+
};
54+
}
55+
56+
private static INamedTypeSymbol? GetTriggerType(GeneratorSyntaxContext context)
57+
{
58+
if (context.Node is not InvocationExpressionSyntax invocation)
59+
{
60+
return null;
61+
}
62+
63+
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
64+
if (symbolInfo.Symbol is not IMethodSymbol { Name: CreateMethodName } methodSymbol)
65+
{
66+
return null;
67+
}
68+
69+
var containingType = methodSymbol.ContainingType;
70+
if (containingType is null)
71+
{
72+
return null;
73+
}
74+
75+
// Match both 4-param (with data) and 3-param (NoData) StateMachine
76+
var metadataName = containingType.OriginalDefinition.MetadataName;
77+
if (!string.Equals(metadataName, StateMachineMetadataName4, StringComparison.Ordinal) &&
78+
!string.Equals(metadataName, StateMachineMetadataName3, StringComparison.Ordinal))
79+
{
80+
return null;
81+
}
82+
83+
if (!string.Equals(
84+
containingType.ContainingNamespace?.ToDisplayString(),
85+
StateMachineNamespace,
86+
StringComparison.Ordinal))
87+
{
88+
return null;
89+
}
90+
91+
// TTrigger is always the 2nd type argument (index 1)
92+
if (containingType.TypeArguments.Length < 2)
93+
{
94+
return null;
95+
}
96+
97+
return containingType.TypeArguments[1] as INamedTypeSymbol;
98+
}
99+
100+
private static void Generate(
101+
SourceProductionContext context,
102+
Compilation compilation,
103+
IReadOnlyList<INamedTypeSymbol> triggerBaseTypes)
104+
{
105+
// Only generate if [ModuleInitializer] is available in the target framework
106+
var moduleInitAttr = compilation.GetTypeByMetadataName(
107+
"System.Runtime.CompilerServices.ModuleInitializerAttribute");
108+
if (moduleInitAttr is null)
109+
{
110+
return;
111+
}
112+
113+
// Collect all types in the current assembly (including nested)
114+
var allTypes = new List<INamedTypeSymbol>();
115+
CollectTypes(compilation.Assembly.GlobalNamespace, allTypes);
116+
117+
// Deduplicate trigger base types
118+
var uniqueTriggerTypes = new List<INamedTypeSymbol>();
119+
var seen = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
120+
foreach (var t in triggerBaseTypes)
121+
{
122+
if (seen.Add(t))
123+
{
124+
uniqueTriggerTypes.Add(t);
125+
}
126+
}
127+
128+
var source = new StringBuilder();
129+
source.AppendLine("// <auto-generated />");
130+
source.AppendLine("using System;");
131+
source.AppendLine("using System.Runtime.CompilerServices;");
132+
source.AppendLine("using FunctionalStateMachine.Core;");
133+
source.AppendLine();
134+
source.AppendLine("namespace FunctionalStateMachine.Core.Generated");
135+
source.AppendLine("{");
136+
source.AppendLine(" internal static class TriggerTypeRegistrationBootstrap");
137+
source.AppendLine(" {");
138+
source.AppendLine(" [ModuleInitializer]");
139+
source.AppendLine(" internal static void Initialize()");
140+
source.AppendLine(" {");
141+
142+
foreach (var triggerBaseType in uniqueTriggerTypes)
143+
{
144+
// Skip trigger types that are not accessible from the generated module initializer
145+
if (!IsAccessibleFromAssembly(triggerBaseType))
146+
{
147+
continue;
148+
}
149+
150+
var concreteTypes = GetConcreteTypes(triggerBaseType, allTypes);
151+
152+
// Nothing to register if no accessible concrete types were found
153+
if (concreteTypes.Count == 0)
154+
{
155+
continue;
156+
}
157+
158+
var baseTypeName = triggerBaseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
159+
var typeArray = string.Join(", ", concreteTypes.Select(t =>
160+
$"typeof({t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)})"));
161+
source.AppendLine(
162+
$" TriggerTypeRegistry.Register<{baseTypeName}>(new[] {{ {typeArray} }});");
163+
}
164+
165+
source.AppendLine(" }");
166+
source.AppendLine(" }");
167+
source.AppendLine("}");
168+
169+
context.AddSource("TriggerTypeRegistry.g.cs", source.ToString());
170+
}
171+
172+
private static List<INamedTypeSymbol> GetConcreteTypes(
173+
INamedTypeSymbol baseType,
174+
List<INamedTypeSymbol> allTypes)
175+
{
176+
// For enum TTrigger, register the enum type itself (no subtypes)
177+
if (baseType.TypeKind == TypeKind.Enum)
178+
{
179+
return new List<INamedTypeSymbol> { baseType };
180+
}
181+
182+
// Find all non-abstract, non-generic types that derive from the base type
183+
// Only include types that are accessible from the generated module initializer
184+
var derivedTypes = allTypes
185+
.Where(t => !t.IsAbstract && t.TypeParameters.IsEmpty)
186+
.Where(t => !SymbolEqualityComparer.Default.Equals(t, baseType) && IsAssignableTo(t, baseType))
187+
.Where(IsAccessibleFromAssembly)
188+
.OrderBy(t => t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), StringComparer.Ordinal)
189+
.ToList();
190+
191+
// If no derived types found, register the base type itself as a fallback
192+
// (only if the base type itself is accessible)
193+
if (derivedTypes.Count == 0 && IsAccessibleFromAssembly(baseType))
194+
{
195+
return new List<INamedTypeSymbol> { baseType };
196+
}
197+
198+
return derivedTypes;
199+
}
200+
201+
/// <summary>
202+
/// Returns true if the type and all its containing types are at least internal,
203+
/// making them accessible from the generated module initializer class in the same assembly.
204+
/// </summary>
205+
private static bool IsAccessibleFromAssembly(INamedTypeSymbol type)
206+
{
207+
var current = type;
208+
while (current is not null)
209+
{
210+
switch (current.DeclaredAccessibility)
211+
{
212+
case Accessibility.Private:
213+
case Accessibility.Protected:
214+
case Accessibility.ProtectedAndInternal:
215+
return false;
216+
}
217+
218+
current = current.ContainingType;
219+
}
220+
221+
return true;
222+
}
223+
224+
private static bool IsAssignableTo(INamedTypeSymbol type, INamedTypeSymbol baseType)
225+
{
226+
var current = type.BaseType;
227+
while (current is not null)
228+
{
229+
if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, baseType.OriginalDefinition))
230+
{
231+
return true;
232+
}
233+
234+
current = current.BaseType;
235+
}
236+
237+
return false;
238+
}
239+
240+
private static void CollectTypes(INamespaceSymbol namespaceSymbol, List<INamedTypeSymbol> types)
241+
{
242+
foreach (var type in namespaceSymbol.GetTypeMembers())
243+
{
244+
types.Add(type);
245+
CollectNestedTypes(type, types);
246+
}
247+
248+
foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers())
249+
{
250+
CollectTypes(nestedNamespace, types);
251+
}
252+
}
253+
254+
private static void CollectNestedTypes(INamedTypeSymbol type, List<INamedTypeSymbol> types)
255+
{
256+
foreach (var nested in type.GetTypeMembers())
257+
{
258+
types.Add(nested);
259+
CollectNestedTypes(nested, types);
260+
}
261+
}
262+
}

src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,22 @@
2222
<PackageReference Include="PolySharp" Version="1.15.0" PrivateAssets="all" />
2323
</ItemGroup>
2424

25+
<ItemGroup>
26+
<ProjectReference Include="..\FunctionalStateMachine.Core.Generator\FunctionalStateMachine.Core.Generator.csproj"
27+
OutputItemType="Analyzer"
28+
ReferenceOutputAssembly="false" />
29+
</ItemGroup>
30+
31+
<ItemGroup>
32+
<None Include="..\FunctionalStateMachine.Core.Generator\bin\$(Configuration)\netstandard2.0\FunctionalStateMachine.Core.Generator.dll"
33+
Pack="true"
34+
PackagePath="analyzers/dotnet/cs"
35+
Visible="false" />
36+
<None Include="..\FunctionalStateMachine.Core.Generator\bin\$(Configuration)\netstandard2.0\FunctionalStateMachine.Core.Generator.pdb"
37+
Pack="true"
38+
PackagePath="analyzers/dotnet/cs"
39+
Visible="false"
40+
Condition="Exists('..\FunctionalStateMachine.Core.Generator\bin\$(Configuration)\netstandard2.0\FunctionalStateMachine.Core.Generator.pdb')" />
41+
</ItemGroup>
42+
2543
</Project>

0 commit comments

Comments
 (0)