Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions FunctionalStateMachine.sln
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalStateMachine.Benc
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalStateMachine.Core.Generator", "src\FunctionalStateMachine.Core.Generator\FunctionalStateMachine.Core.Generator.csproj", "{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -231,6 +233,18 @@ Global
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x64.Build.0 = Release|Any CPU
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x86.ActiveCfg = Release|Any CPU
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x86.Build.0 = Release|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x64.ActiveCfg = Debug|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x64.Build.0 = Debug|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x86.ActiveCfg = Debug|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x86.Build.0 = Debug|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|Any CPU.Build.0 = Release|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x64.ActiveCfg = Release|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x64.Build.0 = Release|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x86.ActiveCfg = Release|Any CPU
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -251,5 +265,6 @@ Global
{D1FA70BE-F53F-492A-83E3-A1D34267795E} = {A96AD5CA-C4AA-45C6-A86D-69F8ED944894}
{E5866FE3-15DF-4362-95A6-C6F651434249} = {A96AD5CA-C4AA-45C6-A86D-69F8ED944894}
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555} = {0C88DD14-F956-CE84-757C-A364CCF449FC}
{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,15 @@ Explore complete, runnable examples in the `/samples` directory:

---

## Advanced Topics

This library targets both `netstandard2.0` (broad compatibility) and `net8.0` (AOT/trim-ready). Both NativeAOT and `PublishTrimmed` publishing are supported with zero runtime reflection.

- **[Target framework compatibility](docs/Target-Framework-Compatibility.md)** — .NET Standard 2.0 vs .NET 8+, what each target provides
- **[AOT and trim compatibility](docs/AOT-and-Trim-Compatibility.md)** — NativeAOT, `PublishTrimmed`, how source generators eliminate reflection

---

## Installation

```bash
Expand Down
122 changes: 122 additions & 0 deletions docs/AOT-and-Trim-Compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# AOT and Trim Compatibility

The `net8.0` build of `FunctionalStateMachine.Core` and `FunctionalStateMachine.CommandRunner` is fully compatible with:

- **NativeAOT** (`PublishAot=true`) — compiled ahead of time to a self-contained native binary
- **Trimming** (`PublishTrimmed=true`) — unused code removed at publish time to reduce binary size
- **Single-file publishing** (`PublishSingleFile=true`)

## Status

| Package | `IsAotCompatible` | Reflection-free | Trim-safe |
|---|---|---|---|
| `FunctionalStateMachine.Core` | ✅ (`net8.0+`) | ✅ | ✅ |
| `FunctionalStateMachine.CommandRunner` | ✅ (`net8.0+`) | ✅ | ✅ |
| `FunctionalStateMachine.Diagrams` | N/A (build-time only) | N/A | N/A |
| `FunctionalStateMachine.Core.Generator` | N/A (build-time only) | N/A | N/A |
| `FunctionalStateMachine.CommandRunner.Generator` | N/A (build-time only) | N/A | N/A |

The two generators (`Core.Generator` and `CommandRunner.Generator`) are Roslyn analyzers that run at compile time inside the compiler process, not in your application. They are never published as part of your app binary.

## Enabling publishing with trimming

### Executable projects

Add `<PublishTrimmed>true</PublishTrimmed>` to your `.csproj`:

```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
</Project>
```

The source generator is bundled inside the `FunctionalStateMachine.Core` NuGet package and applied automatically — no extra package reference needed.

Then publish:

```bash
dotnet publish -c Release -r linux-x64 --sc true
```

### NativeAOT

```xml
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
```

```bash
dotnet publish -c Release -r linux-x64
```

## How it works: no reflection at runtime

The library was originally written to warn about unused trigger types at state machine build time. This required knowing all defined trigger subtypes — which was initially discovered via `Assembly.GetTypes()` and `Type.GetProperty()` (reflection).

Both reflection APIs are incompatible with AOT/trimming because the trimmer may remove types it doesn't see referenced, and `Assembly.GetTypes()` only returns what survives trimming.

### The source generator approach

The `FunctionalStateMachine.Core.Generator` Roslyn source generator solves this at compile time:

1. It detects all `StateMachine<…, TTrigger, …>.Create()` call sites in your compilation.
2. For each unique `TTrigger`, it finds all non-abstract concrete derived types using the Roslyn symbol API (no runtime reflection).
3. It generates a `[ModuleInitializer]` that registers those types in `TriggerTypeRegistry` before any app code runs.

```csharp
// Example of generated output (in your bin/obj folder):
// <auto-generated />
[ModuleInitializer]
internal static void Initialize()
{
TriggerTypeRegistry.Register<global::MyApp.OrderTrigger>(new[]
{
typeof(global::MyApp.OrderTrigger.Process),
typeof(global::MyApp.OrderTrigger.Cancel),
typeof(global::MyApp.OrderTrigger.Complete),
});
}
```

At runtime, `AnalyzeUnusedTriggers` reads from this registry — no assembly scanning, no reflection. If the registry has not been populated (e.g. the generator wasn't active for that trigger type), the check is silently skipped rather than throwing.

### CommandRunner dispatcher

`FunctionalStateMachine.CommandRunner` also uses a source generator (`CommandRunner.Generator`) to produce a type-switching dispatcher at compile time. The dispatcher uses a `switch` on concrete command types rather than any reflection-based dispatch, making it fully AOT- and trim-safe.

## Multiple state machines sharing the same trigger type

When several state machines in the same project share the same `TTrigger`, the generator registers the trigger types once (deduplicated by trigger base type). Each machine's unused-trigger analysis runs independently at `.Build()` time:

```csharp
// Both machines share OrderTrigger; generator registers its types once.
// machineA may use all triggers; machineB may only use a subset.
// Each machine independently warns about its own unused triggers.

var machineA = StateMachine<StateA, OrderTrigger, DataA, CmdA>.Create()
.For(StateA.Idle)
.On<OrderTrigger.Process>() // uses Process
.TransitionTo(StateA.Done)
// ⚠️ warning: Cancel and Complete unused in this machine
.Build();

var machineB = StateMachine<StateB, OrderTrigger, DataB, CmdB>.Create()
.For(StateB.Idle)
.On<OrderTrigger.Cancel>() // uses Cancel
.TransitionTo(StateB.Cancelled)
// ⚠️ warning: Process and Complete unused in this machine
.Build();
```

Both machines build successfully — unused-trigger warnings are informational and never block construction.

## Related pages

- [Target Framework Compatibility](./Target-Framework-Compatibility.md)
- [Packages](./Packages.md)
- [Static Analysis](./Static-Analysis-for-State-Machine-Configuration.md)
48 changes: 48 additions & 0 deletions docs/Target-Framework-Compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Target Framework Compatibility

`FunctionalStateMachine.Core` and `FunctionalStateMachine.CommandRunner` both multi-target so the correct build is selected automatically based on your project's target framework.

## Supported targets

| Target framework | Gets |
|---|---|
| `netstandard2.0` | Broadest compatibility — .NET Framework 4.6.1+, .NET Core 2.0+, Xamarin, Unity |
| `net8.0` | AOT-compatible build with full trim analysis |

When you reference the NuGet package from a `net8.0` or `net9.0` project, NuGet automatically selects the `net8.0` build. When you reference it from a `netstandard2.0`-compatible target, NuGet selects the `netstandard2.0` build.

## Feature comparison

All state machine features are identical across both targets. The only difference is in the internals:

| Feature | `netstandard2.0` | `net8.0+` |
|---|---|---|
| Full fluent API | ✅ | ✅ |
| Guards, conditionals | ✅ | ✅ |
| Hierarchical states | ✅ | ✅ |
| Static analysis on `.Build()` | ✅ | ✅ |
| Unused-trigger analysis | ✅ (when generator active) | ✅ (when generator active) |
| AOT / NativeAOT safe | ⚠️ (N/A for `netstandard2.0`) | ✅ |
| Trim-safe | ⚠️ (N/A for `netstandard2.0`) | ✅ |
| `[ModuleInitializer]` for trigger registry | ✅ (when PolySharp present) | ✅ |

## Source generator compatibility

The source generator (`FunctionalStateMachine.Core.Generator`) is a `netstandard2.0` Roslyn analyzer and works with all target frameworks. The generated `[ModuleInitializer]` code requires the `System.Runtime.CompilerServices.ModuleInitializerAttribute` type, which is:

- Available natively in `.NET 5` and later
- Back-ported to `netstandard2.0` by [PolySharp](https://github.com/Sergio0694/PolySharp) if you have it installed

If neither is available (e.g. plain `netstandard2.0` without PolySharp), the generator silently skips generation and unused-trigger analysis is bypassed without error.

## Using with .NET Framework

If your project targets `.NET Framework`, the `netstandard2.0` build is used. The state machine works fully, but:

- Unused-trigger analysis will not report warnings (no `[ModuleInitializer]` support without PolySharp)
- AOT and trim tooling are not applicable

## Related pages

- [AOT and Trim Compatibility](./AOT-and-Trim-Compatibility.md)
- [Packages](./Packages.md)
5 changes: 5 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ Welcome to the Functional State Machine docs. Each guide introduces a feature, w
- [Mermaid diagram generation](Mermaid-Diagram-Generation.md)
- [Command runners](Command-Runners.md)

## Advanced

- [Target framework compatibility (.NET Standard 2.0 vs .NET 8+)](Target-Framework-Compatibility.md)
- [AOT and trim compatibility](AOT-and-Trim-Compatibility.md)

## For Contributors

- [AI documentation maintenance guide](AI-Documentation-Maintenance.md) - How to keep AI docs up-to-date
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

<ItemGroup>
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Core\FunctionalStateMachine.Core.csproj" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Core.Generator\FunctionalStateMachine.Core.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Diagrams\FunctionalStateMachine.Diagrams.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Core\FunctionalStateMachine.Core.csproj" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Core.Generator\FunctionalStateMachine.Core.Generator.csproj"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot - how does someone consuming the package from Nuget do this? Does the consumer need to reference the core package and add the generator package as a reference or can we make referencing the main package sufficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Referencing FunctionalStateMachine.Core from NuGet is sufficient — no separate generator package needed. The generator DLL is bundled inside the Core NuGet package at analyzers/dotnet/cs, which NuGet automatically applies as an analyzer.

The explicit Core.Generator project reference in the sample apps is only necessary because they use project references (not NuGet packages). That's the exact equivalent of what NuGet does automatically for package consumers.

OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Diagrams\FunctionalStateMachine.Diagrams.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>

<ItemGroup>
Expand All @@ -17,6 +18,9 @@
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.CommandRunner\FunctionalStateMachine.CommandRunner.csproj" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Core\FunctionalStateMachine.Core.csproj" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Core.Generator\FunctionalStateMachine.Core.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\src\FunctionalStateMachine.Diagrams\FunctionalStateMachine.Diagrams.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<NoWarn>$(NoWarn);RS1035</NoWarn>
<IsPackable>false</IsPackable>

<!-- This is a build-time-only analyzer; do not trim or publish it as a runtime dependency -->
<IsPublishable>false</IsPublishable>
<PublishTrimmed>false</PublishTrimmed>
<PublishAot>false</PublishAot>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand All @@ -12,11 +12,20 @@
<!-- XML Documentation -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>

<!-- AOT and trimming compatibility (net8.0+) -->
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
<!-- Prevent trim/AOT flags from being applied to the netstandard2.0 build by consuming apps -->
<PublishTrimmed Condition="'$(TargetFramework)' == 'netstandard2.0'">false</PublishTrimmed>
<PublishAot Condition="'$(TargetFramework)' == 'netstandard2.0'">false</PublishAot>
</PropertyGroup>

<!-- Polyfills for C# 9+ features on netstandard2.0 -->
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="PolySharp" Version="1.15.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.2.0" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<NoWarn>$(NoWarn);RS1035</NoWarn>
<IsPackable>false</IsPackable>

<!-- This is a build-time-only analyzer; do not trim or publish it as a runtime dependency -->
<IsPublishable>false</IsPublishable>
<PublishTrimmed>false</PublishTrimmed>
<PublishAot>false</PublishAot>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
</ItemGroup>

</Project>
Loading
Loading