diff --git a/docs/generators/state-machine.md b/docs/generators/state-machine.md new file mode 100644 index 0000000..13f0293 --- /dev/null +++ b/docs/generators/state-machine.md @@ -0,0 +1,913 @@ +# State Machine Pattern Generator + +The State Machine Pattern Generator automatically creates deterministic finite state machines with explicit states, triggers, guards, and lifecycle hooks. It eliminates boilerplate code for state management while providing compile-time type safety, async/await support, and configurable error handling policies. + +## Overview + +The generator produces: + +- **State property** to track the current state +- **Fire method** for synchronous state transitions +- **FireAsync method** for asynchronous workflows with ValueTask and CancellationToken support +- **CanFire method** to check if a trigger is valid for the current state +- **Deterministic transition resolution** based on (FromState, Trigger) pairs +- **Guard evaluation** with configurable failure policies +- **Entry/exit hooks** for state lifecycle management +- **Zero runtime overhead** through source generation + +## Quick Start + +### 1. Define Your States and Triggers + +Define enums for your states and triggers: + +```csharp +using PatternKit.Generators.State; + +public enum OrderState +{ + Draft, + Submitted, + Paid, + Shipped, + Cancelled +} + +public enum OrderTrigger +{ + Submit, + Pay, + Ship, + Cancel +} +``` + +### 2. Create Your State Machine Host + +Mark your class with `[StateMachine]` and define transitions: + +```csharp +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + public string Id { get; } + public decimal Amount { get; } + + public OrderFlow(string id, decimal amount) + { + Id = id; + Amount = amount; + State = OrderState.Draft; // Set initial state + } + + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() + { + Console.WriteLine($"Order {Id} submitted"); + } + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private void OnPay() + { + Console.WriteLine($"Payment processed for {Id}"); + } + + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Ship, To = OrderState.Shipped)] + private void OnShip() + { + Console.WriteLine($"Order {Id} shipped"); + } +} +``` + +### 3. Build Your Project + +The generator runs during compilation and produces the state machine implementation: + +```csharp +var order = new OrderFlow("ORD-001", 299.99m); + +Console.WriteLine($"Current state: {order.State}"); // Draft + +order.Fire(OrderTrigger.Submit); +Console.WriteLine($"Current state: {order.State}"); // Submitted + +order.Fire(OrderTrigger.Pay); +Console.WriteLine($"Current state: {order.State}"); // Paid + +order.Fire(OrderTrigger.Ship); +Console.WriteLine($"Current state: {order.State}"); // Shipped +``` + +### 4. Generated Code + +```csharp +partial class OrderFlow +{ + public OrderState State { get; private set; } + + public bool CanFire(OrderTrigger trigger) + { + return (State, trigger) switch + { + (OrderState.Draft, OrderTrigger.Submit) => true, + (OrderState.Submitted, OrderTrigger.Pay) => true, + (OrderState.Paid, OrderTrigger.Ship) => true, + _ => false + }; + } + + public void Fire(OrderTrigger trigger) + { + switch (State) + { + case OrderState.Draft: + switch (trigger) + { + case OrderTrigger.Submit: + OnSubmit(); + State = OrderState.Submitted; + return; + } + break; + // ... more cases + } + + throw new InvalidOperationException($"Invalid trigger {trigger} for state {State}"); + } +} +``` + +## Core Features + +### Guards + +Guards control whether a transition is allowed based on runtime conditions: + +```csharp +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + public decimal Amount { get; } + public bool IsPaymentAuthorized { get; set; } + + [StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] + private bool CanPay() + { + return Amount > 0 && Amount < 10000 && IsPaymentAuthorized; + } + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private void OnPay() + { + Console.WriteLine($"Processing payment of ${Amount}"); + } +} +``` + +**Usage:** +```csharp +var order = new OrderFlow("ORD-001", 150.00m); +order.Fire(OrderTrigger.Submit); + +if (order.CanFire(OrderTrigger.Pay)) +{ + order.Fire(OrderTrigger.Pay); // Only fires if guard passes +} +``` + +### Entry and Exit Hooks + +Execute code when entering or exiting specific states: + +```csharp +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + [StateExit(OrderState.Paid)] + private void OnExitPaid() + { + Console.WriteLine("Finalizing payment transaction"); + // Send payment confirmation email + // Update inventory + } + + [StateEntry(OrderState.Shipped)] + private void OnEnterShipped() + { + Console.WriteLine("Order is being shipped"); + // Send shipping notification + // Generate tracking number + // Update shipping status + } + + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Ship, To = OrderState.Shipped)] + private void OnShip() + { + Console.WriteLine("Preparing shipment"); + } +} +``` + +**Execution Order:** +1. Exit hooks for `FromState` (if any) +2. Transition action method (`[StateTransition]`) (if any) +3. Update `State = ToState` +4. Entry hooks for `ToState` (if any) + +### Async Support + +The generator automatically detects async methods and generates `FireAsync`: + +```csharp +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private async ValueTask OnPayAsync(CancellationToken ct) + { + Console.WriteLine("Processing payment..."); + await ProcessPaymentAsync(ct); + await SendConfirmationEmailAsync(ct); + } + + [StateEntry(OrderState.Shipped)] + private async ValueTask OnEnterShippedAsync(CancellationToken ct) + { + await NotifyShippingServiceAsync(ct); + await UpdateTrackingSystemAsync(ct); + } +} +``` + +**Usage:** +```csharp +var order = new OrderFlow("ORD-001", 299.99m); +order.Fire(OrderTrigger.Submit); + +// Use async method for async transitions +await order.FireAsync(OrderTrigger.Pay, cancellationToken); +await order.FireAsync(OrderTrigger.Ship, cancellationToken); +``` + +### Async Guards + +Guards can also be async to support I/O operations: + +```csharp +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + [StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] + private async ValueTask CanPayAsync(CancellationToken ct) + { + // Check with payment service + return await PaymentService.IsAuthorizedAsync(Id, Amount, ct); + } + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private async ValueTask OnPayAsync(CancellationToken ct) + { + await ProcessPaymentAsync(ct); + } +} +``` + +**Note:** Async guards are evaluated synchronously in `CanFire()` using `GetAwaiter().GetResult()`. Use `FireAsync()` for proper async execution. + +### Multiple Transitions from Same State + +You can define multiple valid transitions from a single state: + +```csharp +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + // Allow cancellation from multiple states + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + private void OnCancel() + { + Console.WriteLine($"Order {Id} cancelled"); + } + + [StateEntry(OrderState.Cancelled)] + private void OnEnterCancelled() + { + // Process refund if applicable + // Send cancellation notification + } +} +``` + +## Configuration Options + +### Custom Method Names + +Customize the names of generated methods: + +```csharp +[StateMachine( + typeof(OrderState), + typeof(OrderTrigger), + FireMethodName = "Transition", + FireAsyncMethodName = "TransitionAsync", + CanFireMethodName = "CanTransition")] +public partial class OrderFlow +{ + // Will generate: Transition(), TransitionAsync(), CanTransition() +} +``` + +### Error Handling Policies + +#### Invalid Trigger Policy + +Controls what happens when an invalid trigger is fired: + +```csharp +[StateMachine( + typeof(OrderState), + typeof(OrderTrigger), + InvalidTrigger = StateMachineInvalidTriggerPolicy.Throw)] // Default +public partial class OrderFlow +{ + // Throws InvalidOperationException on invalid trigger +} + +[StateMachine( + typeof(OrderState), + typeof(OrderTrigger), + InvalidTrigger = StateMachineInvalidTriggerPolicy.Ignore)] +public partial class OrderFlow +{ + // Silently ignores invalid triggers +} +``` + +**Available Policies:** +- `Throw` (default) - Throws `InvalidOperationException` +- `Ignore` - Does nothing, returns silently + +#### Guard Failure Policy + +Controls what happens when a guard returns false: + +```csharp +[StateMachine( + typeof(OrderState), + typeof(OrderTrigger), + GuardFailure = StateMachineGuardFailurePolicy.Throw)] // Default +public partial class OrderFlow +{ + // Throws InvalidOperationException when guard fails +} + +[StateMachine( + typeof(OrderState), + typeof(OrderTrigger), + GuardFailure = StateMachineGuardFailurePolicy.Ignore)] +public partial class OrderFlow +{ + // Silently ignores when guard fails +} +``` + +**Available Policies:** +- `Throw` (default) - Throws `InvalidOperationException` +- `Ignore` - Does nothing, returns silently + +### Async Generation Control + +Control async method generation explicitly: + +```csharp +// Force async generation even without async methods +[StateMachine( + typeof(OrderState), + typeof(OrderTrigger), + ForceAsync = true)] +public partial class OrderFlow +{ + // Always generates FireAsync even for sync-only transitions +} + +// Explicitly disable async generation (warning if async methods exist) +[StateMachine( + typeof(OrderState), + typeof(OrderTrigger), + GenerateAsync = false)] +public partial class OrderFlow +{ + // Will emit PKST008 warning if async methods are present +} +``` + +## Supported Target Types + +The state machine generator supports: + +- **partial class** +- **partial struct** +- **partial record class** +- **partial record struct** + +```csharp +// Class +[StateMachine(typeof(State), typeof(Trigger))] +public partial class OrderStateMachine { } + +// Struct (for high-performance scenarios) +[StateMachine(typeof(State), typeof(Trigger))] +public partial struct LightweightStateMachine { } + +// Record class (immutable by convention) +[StateMachine(typeof(State), typeof(Trigger))] +public partial record class OrderRecord { } + +// Record struct +[StateMachine(typeof(State), typeof(Trigger))] +public partial record struct CompactStateMachine { } +``` + +## Real-World Examples + +### Order Processing Workflow + +```csharp +public enum OrderState { Draft, Submitted, Paid, Shipped, Delivered, Cancelled, Refunded } +public enum OrderTrigger { Submit, Pay, Ship, Deliver, Cancel, Refund } + +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderWorkflow +{ + public string OrderId { get; } + public decimal Amount { get; } + public DateTime? PaidAt { get; private set; } + public DateTime? ShippedAt { get; private set; } + + public OrderWorkflow(string orderId, decimal amount) + { + OrderId = orderId; + Amount = amount; + State = OrderState.Draft; + } + + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() + { + // Validate order + Console.WriteLine($"Order {OrderId} submitted"); + } + + [StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] + private bool CanPay() => Amount > 0 && Amount < 100000; + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private async ValueTask OnPayAsync(CancellationToken ct) + { + await ProcessPaymentAsync(ct); + PaidAt = DateTime.UtcNow; + Console.WriteLine($"Payment of ${Amount} processed for order {OrderId}"); + } + + [StateExit(OrderState.Paid)] + private void OnExitPaid() + { + Console.WriteLine("Finalizing payment records"); + } + + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Ship, To = OrderState.Shipped)] + private async ValueTask OnShipAsync(CancellationToken ct) + { + await NotifyShippingServiceAsync(ct); + ShippedAt = DateTime.UtcNow; + Console.WriteLine($"Order {OrderId} shipped"); + } + + [StateEntry(OrderState.Shipped)] + private async ValueTask OnEnterShippedAsync(CancellationToken ct) + { + await SendTrackingNotificationAsync(ct); + Console.WriteLine("Tracking notification sent"); + } + + [StateTransition(From = OrderState.Shipped, Trigger = OrderTrigger.Deliver, To = OrderState.Delivered)] + private void OnDeliver() + { + Console.WriteLine($"Order {OrderId} delivered"); + } + + // Cancellation transitions + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + private void OnCancel() + { + Console.WriteLine($"Order {OrderId} cancelled"); + } + + // Refund transition + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Refund, To = OrderState.Refunded)] + [StateTransition(From = OrderState.Shipped, Trigger = OrderTrigger.Refund, To = OrderState.Refunded)] + [StateTransition(From = OrderState.Delivered, Trigger = OrderTrigger.Refund, To = OrderState.Refunded)] + private async ValueTask OnRefundAsync(CancellationToken ct) + { + await ProcessRefundAsync(ct); + Console.WriteLine($"Refund processed for order {OrderId}"); + } + + private Task ProcessPaymentAsync(CancellationToken ct) => Task.Delay(100, ct); + private Task NotifyShippingServiceAsync(CancellationToken ct) => Task.Delay(50, ct); + private Task SendTrackingNotificationAsync(CancellationToken ct) => Task.Delay(50, ct); + private Task ProcessRefundAsync(CancellationToken ct) => Task.Delay(100, ct); +} +``` + +### Document Approval Workflow + +```csharp +public enum DocumentState { Draft, PendingReview, Approved, Rejected, Published, Archived } +public enum DocumentAction { SubmitForReview, Approve, Reject, Publish, Archive, Revise } + +[StateMachine(typeof(DocumentState), typeof(DocumentAction))] +public partial class DocumentWorkflow +{ + public string DocumentId { get; } + public string CurrentReviewer { get; private set; } = string.Empty; + public List ApprovalHistory { get; } = new(); + + public DocumentWorkflow(string documentId) + { + DocumentId = documentId; + State = DocumentState.Draft; + } + + [StateTransition(From = DocumentState.Draft, Trigger = DocumentAction.SubmitForReview, To = DocumentState.PendingReview)] + private void OnSubmitForReview() + { + CurrentReviewer = "reviewer@example.com"; + Console.WriteLine($"Document {DocumentId} submitted for review to {CurrentReviewer}"); + } + + [StateGuard(From = DocumentState.PendingReview, Trigger = DocumentAction.Approve)] + private bool CanApprove() + { + return !string.IsNullOrEmpty(CurrentReviewer); + } + + [StateTransition(From = DocumentState.PendingReview, Trigger = DocumentAction.Approve, To = DocumentState.Approved)] + private void OnApprove() + { + ApprovalHistory.Add($"{CurrentReviewer} approved at {DateTime.UtcNow}"); + Console.WriteLine($"Document {DocumentId} approved by {CurrentReviewer}"); + } + + [StateTransition(From = DocumentState.PendingReview, Trigger = DocumentAction.Reject, To = DocumentState.Rejected)] + private void OnReject() + { + ApprovalHistory.Add($"{CurrentReviewer} rejected at {DateTime.UtcNow}"); + Console.WriteLine($"Document {DocumentId} rejected by {CurrentReviewer}"); + } + + [StateTransition(From = DocumentState.Rejected, Trigger = DocumentAction.Revise, To = DocumentState.Draft)] + private void OnRevise() + { + CurrentReviewer = string.Empty; + Console.WriteLine($"Document {DocumentId} sent back to draft for revision"); + } + + [StateTransition(From = DocumentState.Approved, Trigger = DocumentAction.Publish, To = DocumentState.Published)] + private async ValueTask OnPublishAsync(CancellationToken ct) + { + await PublishToContentManagementSystemAsync(ct); + Console.WriteLine($"Document {DocumentId} published"); + } + + [StateEntry(DocumentState.Published)] + private void OnEnterPublished() + { + Console.WriteLine("Document is now publicly visible"); + } + + [StateTransition(From = DocumentState.Published, Trigger = DocumentAction.Archive, To = DocumentState.Archived)] + private void OnArchive() + { + Console.WriteLine($"Document {DocumentId} archived"); + } + + private Task PublishToContentManagementSystemAsync(CancellationToken ct) => Task.Delay(200, ct); +} +``` + +## Best Practices + +### 1. Define Clear State Enums + +Use descriptive names that reflect business states: + +```csharp +// Good +public enum OrderState { Draft, Submitted, Paid, Shipped, Delivered } + +// Avoid +public enum State { S1, S2, S3, S4, S5 } +``` + +### 2. Use Guards for Business Rules + +Centralize validation logic in guards: + +```csharp +[StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] +private bool CanPay() +{ + return Amount > 0 && + Amount < MaxAllowedAmount && + PaymentMethod != null && + !IsBlacklisted; +} +``` + +### 3. Keep Transition Methods Focused + +Each transition method should do one thing: + +```csharp +[StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] +private async ValueTask OnPayAsync(CancellationToken ct) +{ + await ProcessPaymentAsync(ct); + // Don't mix concerns - handle notification in entry hook +} + +[StateEntry(OrderState.Paid)] +private async ValueTask OnEnterPaidAsync(CancellationToken ct) +{ + await SendPaymentConfirmationAsync(ct); +} +``` + +### 4. Use Async for I/O Operations + +Prefer `ValueTask` for async operations: + +```csharp +[StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Ship, To = OrderState.Shipped)] +private async ValueTask OnShipAsync(CancellationToken ct) +{ + await ShippingService.CreateShipmentAsync(Id, ct); +} +``` + +### 5. Document Complex Workflows + +Add XML documentation to help users understand the state machine: + +```csharp +/// +/// Manages the order fulfillment workflow from creation to delivery. +/// States: Draft -> Submitted -> Paid -> Shipped -> Delivered +/// +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderWorkflow +{ + /// + /// Processes payment and transitions to Paid state. + /// Guard: Amount must be > 0 and < $100,000 + /// + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private async ValueTask OnPayAsync(CancellationToken ct) + { + await ProcessPaymentAsync(ct); + } +} +``` + +## Diagnostics + +The generator provides comprehensive compile-time diagnostics: + +| ID | Severity | Description | +|----|----------|-------------| +| **PKST001** | Error | Type marked with [StateMachine] must be partial | +| **PKST002** | Error | State type must be an enum | +| **PKST003** | Error | Trigger type must be an enum | +| **PKST004** | Error | Duplicate transition detected for (From, Trigger) | +| **PKST005** | Error | Transition method signature invalid | +| **PKST006** | Error | Guard method signature invalid | +| **PKST007** | Error | Entry/Exit hook signature invalid | +| **PKST008** | Warning | Async method detected but async generation disabled | +| **PKST009** | Error | Generic types not supported | +| **PKST010** | Error | Nested types not supported | + +### Common Errors and Solutions + +#### PKST001: Type not partial + +**Error:** +```csharp +[StateMachine(typeof(State), typeof(Trigger))] +public class OrderFlow // Missing 'partial' +{ +} +``` + +**Solution:** +```csharp +[StateMachine(typeof(State), typeof(Trigger))] +public partial class OrderFlow // Add 'partial' +{ +} +``` + +#### PKST004: Duplicate transitions + +**Error:** +```csharp +[StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] +private void OnSubmit1() { } + +[StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Paid)] +private void OnSubmit2() { } // Duplicate! +``` + +**Solution:** Each (From, Trigger) pair must be unique. Consolidate or use guards. + +#### PKST005: Invalid transition signature + +**Error:** +```csharp +[StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] +private int OnSubmit() // Must return void or ValueTask +{ + return 42; +} +``` + +**Solution:** +```csharp +[StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] +private void OnSubmit() // Correct +{ +} +``` + +## Performance Considerations + +### Zero Allocation Path + +The generator produces zero-allocation code for synchronous transitions: + +```csharp +// No boxing, no delegates, no allocations +order.Fire(OrderTrigger.Submit); +``` + +### ValueTask for Async + +Async operations use `ValueTask` to minimize allocations: + +```csharp +// ValueTask can complete synchronously without allocation +await order.FireAsync(OrderTrigger.Pay, ct); +``` + +### Struct State Machines + +For ultra-high-performance scenarios, use struct: + +```csharp +[StateMachine(typeof(State), typeof(Trigger))] +public partial struct HighPerformanceStateMachine +{ + // Entire state machine on the stack +} +``` + +## Migration Guide + +### From Manual Switch Statements + +**Before:** +```csharp +public class OrderFlow +{ + public OrderState State { get; private set; } + + public void Fire(OrderTrigger trigger) + { + switch (State) + { + case OrderState.Draft when trigger == OrderTrigger.Submit: + State = OrderState.Submitted; + OnSubmit(); + break; + // ... many more cases + } + } +} +``` + +**After:** +```csharp +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + + // Compiler generates Fire(), CanFire(), etc. +} +``` + +### From Other State Machine Libraries + +Most state machine libraries use runtime configuration. This generator uses compile-time generation for: +- Better performance (no reflection) +- Better IntelliSense +- Compile-time validation +- Easier debugging + +## FAQ + +### Can I use custom types instead of enums? + +Currently, only enums are supported for states and triggers (v1 limitation). This ensures: +- Compile-time validation +- Optimal performance +- Clear, unambiguous state representation + +### Can I have multiple state machines in one class? + +No, each class can only have one `[StateMachine]` attribute. Consider composition: + +```csharp +public class Order +{ + public OrderWorkflow Workflow { get; } + public PaymentProcessor Payment { get; } +} +``` + +### Is it thread-safe? + +No, the generated state machine is not thread-safe by default. Use external synchronization if needed: + +```csharp +private readonly object _lock = new(); + +public void SafeFire(OrderTrigger trigger) +{ + lock (_lock) + { + _order.Fire(trigger); + } +} +``` + +### Can I persist the state? + +Yes, serialize the `State` property: + +```csharp +var json = JsonSerializer.Serialize(new { order.State, order.OrderId }); +// Save to database + +// Later, restore: +var data = JsonSerializer.Deserialize(json); +var order = new OrderFlow(data.OrderId, amount); +order.State = data.State; // Set via constructor or property +``` + +### How do I test state machines? + +Test transitions independently: + +```csharp +[Fact] +public void Submit_TransitionsToDraftToSubmitted() +{ + var order = new OrderFlow("TEST-001", 100m); + Assert.Equal(OrderState.Draft, order.State); + + order.Fire(OrderTrigger.Submit); + Assert.Equal(OrderState.Submitted, order.State); +} + +[Fact] +public void CanPay_ReturnsFalse_WhenAmountIsZero() +{ + var order = new OrderFlow("TEST-002", 0m); + order.Fire(OrderTrigger.Submit); + + Assert.False(order.CanFire(OrderTrigger.Pay)); +} +``` + +## See Also + +- [State Pattern Examples](../examples/state-machine-examples.md) +- [Template Method Generator](template-method-generator.md) - For sequential workflows +- [Builder Pattern](builder.md) - For object construction +- [Visitor Pattern](visitor-generator.md) - For operation dispatch diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index bb2218a..ed0dbad 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -19,6 +19,9 @@ - name: Proxy href: proxy.md +- name: State Machine + href: state-machine.md + - name: Template Method href: template-method-generator.md diff --git a/src/PatternKit.Examples/Generators/State/OrderFlowDemo.cs b/src/PatternKit.Examples/Generators/State/OrderFlowDemo.cs new file mode 100644 index 0000000..b748058 --- /dev/null +++ b/src/PatternKit.Examples/Generators/State/OrderFlowDemo.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PatternKit.Generators.State; + +namespace PatternKit.Examples.Generators.State; + +/// +/// Real-world example of a State Machine generator demonstrating an order processing workflow. +/// This comprehensive example shows: +/// - Enum-based states and triggers +/// - Synchronous and asynchronous transitions +/// - Guards to control transitions based on business rules +/// - Entry and exit hooks for state changes +/// - Proper cancellation token handling +/// - Multiple scenarios (happy path, cancellation, guard failures) +/// - Error handling with different policies +/// +public static class OrderFlowDemo +{ + /// + /// Runs the main order flow demonstration showing a complete happy-path workflow. + /// + public static void Run() + { + Console.WriteLine("=== Order Flow State Machine Demo ===\n"); + + // Create an order flow instance + var order = new OrderFlow("ORD-001", 299.99m); + + Console.WriteLine($"Order: {order.Id}, Amount: ${order.Amount:F2}"); + Console.WriteLine($"Initial State: {order.State}\n"); + + // Submit the order + Console.WriteLine("1. Submitting order..."); + order.Fire(OrderTrigger.Submit); + Console.WriteLine($" State: {order.State}\n"); + + // Try to pay for the order (will check guard) + Console.WriteLine("2. Attempting to pay..."); + if (order.CanFire(OrderTrigger.Pay)) + { + // This is async, so we'll use RunAsync + RunAsync(order).GetAwaiter().GetResult(); + } + else + { + Console.WriteLine(" Cannot pay for order (guard failed)\n"); + } + + // Ship the order + Console.WriteLine("4. Shipping order..."); + order.Fire(OrderTrigger.Ship); + Console.WriteLine($" State: {order.State}\n"); + + Console.WriteLine("=== Order processing complete ===\n"); + } + + private static async Task RunAsync(OrderFlow order) + { + Console.WriteLine("3. Processing payment..."); + await order.FireAsync(OrderTrigger.Pay, CancellationToken.None); + Console.WriteLine($" State: {order.State}\n"); + } + + /// + /// Demonstrates order cancellation from different states. + /// + public static void CancellationDemo() + { + Console.WriteLine("=== Cancellation Example ===\n"); + + var order = new OrderFlow("ORD-002", 599.99m); + + Console.WriteLine($"Order: {order.Id}, Amount: ${order.Amount:F2}"); + Console.WriteLine($"Initial State: {order.State}\n"); + + // Submit the order + Console.WriteLine("1. Submitting order..."); + order.Fire(OrderTrigger.Submit); + + // Cancel before processing + Console.WriteLine("2. Cancelling order before payment..."); + order.Fire(OrderTrigger.Cancel); + Console.WriteLine($" State: {order.State}\n"); + + Console.WriteLine("=== Order was cancelled ===\n"); + } + + /// + /// Demonstrates guard failure when business rules prevent a transition. + /// + public static void GuardFailureDemo() + { + Console.WriteLine("=== Guard Failure Example ===\n"); + + var order = new OrderFlow("ORD-003", -50m); // Invalid amount + + Console.WriteLine($"Order: {order.Id}, Amount: ${order.Amount:F2}"); + Console.WriteLine($"Initial State: {order.State}\n"); + + // Submit the order + Console.WriteLine("1. Submitting order..."); + order.Fire(OrderTrigger.Submit); + + // Try to pay - guard will fail due to invalid amount + Console.WriteLine("2. Attempting to pay with invalid amount..."); + if (order.CanFire(OrderTrigger.Pay)) + { + Console.WriteLine(" Payment allowed"); + } + else + { + Console.WriteLine(" Payment blocked by guard (invalid amount)\n"); + } + + Console.WriteLine("=== Guard prevented invalid payment ===\n"); + } + + /// + /// Demonstrates async cancellation token handling. + /// + public static async Task AsyncCancellationDemo() + { + Console.WriteLine("=== Async Cancellation Example ===\n"); + + var order = new OrderFlow("ORD-004", 450m); + var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + Console.WriteLine($"Order: {order.Id}, Amount: ${order.Amount:F2}"); + Console.WriteLine("Timeout set to 100ms for 500ms operation\n"); + + order.Fire(OrderTrigger.Submit); + + try + { + Console.WriteLine("Processing payment with timeout..."); + await order.FireAsync(OrderTrigger.Pay, cts.Token); + Console.WriteLine("Payment completed successfully"); + } + catch (OperationCanceledException) + { + Console.WriteLine("Payment operation was cancelled due to timeout"); + order.Fire(OrderTrigger.Cancel); + Console.WriteLine($"Order cancelled. Final state: {order.State}\n"); + } + + Console.WriteLine("=== Async cancellation handled ===\n"); + } + + /// + /// Demonstrates state-based decision making. + /// + public static void StateBasedLogicDemo() + { + Console.WriteLine("=== State-Based Logic Example ===\n"); + + var order = new OrderFlow("ORD-005", 199.99m); + + // Function to display available actions based on current state + void ShowAvailableActions(OrderFlow o) + { + Console.WriteLine($"Current state: {o.State}"); + Console.WriteLine("Available actions:"); + + foreach (OrderTrigger trigger in Enum.GetValues(typeof(OrderTrigger))) + { + if (o.CanFire(trigger)) + { + Console.WriteLine($" - {trigger}"); + } + } + Console.WriteLine(); + } + + ShowAvailableActions(order); + + order.Fire(OrderTrigger.Submit); + ShowAvailableActions(order); + + order.FireAsync(OrderTrigger.Pay, CancellationToken.None).GetAwaiter().GetResult(); + ShowAvailableActions(order); + + Console.WriteLine("=== State-based logic complete ===\n"); + } + + /// + /// Runs all demonstration scenarios. + /// + public static async Task RunAllDemosAsync() + { + Console.WriteLine("╔════════════════════════════════════════════════╗"); + Console.WriteLine("║ State Machine Pattern - Complete Demo Suite ║"); + Console.WriteLine("╔════════════════════════════════════════════════╗\n"); + + Run(); + await Task.Delay(500); + + CancellationDemo(); + await Task.Delay(500); + + GuardFailureDemo(); + await Task.Delay(500); + + await AsyncCancellationDemo(); + await Task.Delay(500); + + StateBasedLogicDemo(); + + Console.WriteLine("╔════════════════════════════════════════════════╗"); + Console.WriteLine("║ All Demonstrations Complete ║"); + Console.WriteLine("╚════════════════════════════════════════════════╝\n"); + } +} + +/// +/// Enum defining the possible states of an order in the fulfillment workflow. +/// +public enum OrderState +{ + /// Initial state - order is being prepared + Draft, + + /// Order has been submitted for processing + Submitted, + + /// Payment has been successfully processed + Paid, + + /// Order has been shipped to the customer + Shipped, + + /// Order was cancelled and will not be processed + Cancelled +} + +/// +/// Enum defining the triggers that can cause state transitions. +/// +public enum OrderTrigger +{ + /// Submit the order for processing + Submit, + + /// Process payment for the order + Pay, + + /// Ship the order to the customer + Ship, + + /// Cancel the order + Cancel +} + +/// +/// State machine for managing order lifecycle using the State Pattern Generator. +/// Demonstrates deterministic state transitions with guards, hooks, and async support. +/// +/// State Flow: +/// Draft -> Submit -> Submitted -> Pay -> Paid -> Ship -> Shipped +/// Draft/Submitted/Paid can -> Cancel -> Cancelled +/// +[StateMachine(typeof(OrderState), typeof(OrderTrigger))] +public partial class OrderFlow +{ + /// + /// Gets the unique identifier for this order. + /// + public string Id { get; } + + /// + /// Gets the order amount. + /// + public decimal Amount { get; } + + /// + /// Initializes a new instance of the OrderFlow state machine. + /// + /// Unique order identifier + /// Order amount in dollars + public OrderFlow(string id, decimal amount) + { + Id = id; + Amount = amount; + State = OrderState.Draft; // Set initial state + } + + #region Transitions + + /// + /// Handles the submission of an order from Draft to Submitted state. + /// + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() + { + Console.WriteLine($" >> Transition: Submitting order {Id}"); + // In a real system: Validate order data, reserve inventory, etc. + } + + /// + /// Guard that validates whether payment can be processed. + /// Checks that the amount is valid (greater than 0). + /// + [StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] + private bool CanPay() + { + // Business rule: Amount must be positive + return Amount > 0; + } + + /// + /// Processes payment asynchronously and transitions from Submitted to Paid state. + /// Simulates payment processing with a delay. + /// + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private async ValueTask OnPayAsync(CancellationToken ct) + { + Console.WriteLine($" >> Transition: Processing payment for {Id}..."); + + // Simulate payment processing + await Task.Delay(500, ct); + + // In a real system: + // - Call payment gateway + // - Update payment records + // - Generate receipt + + Console.WriteLine($" >> Payment of ${Amount:F2} processed"); + } + + /// + /// Exit hook executed when leaving the Paid state. + /// Performs cleanup and finalization tasks. + /// + [StateExit(OrderState.Paid)] + private void OnExitPaid() + { + Console.WriteLine($" >> Exit Hook: Finalizing payment for {Id}"); + // In a real system: + // - Send payment confirmation email + // - Update accounting system + // - Notify warehouse + } + + /// + /// Handles the shipping of an order from Paid to Shipped state. + /// + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Ship, To = OrderState.Shipped)] + private void OnShip() + { + Console.WriteLine($" >> Transition: Shipping order {Id}"); + // In a real system: + // - Generate shipping label + // - Notify shipping carrier + // - Update inventory + } + + /// + /// Entry hook executed when entering the Shipped state. + /// Sends notifications and updates tracking. + /// + [StateEntry(OrderState.Shipped)] + private void OnEnterShipped() + { + Console.WriteLine($" >> Entry Hook: Order {Id} is now shipped, sending notification"); + // In a real system: + // - Send shipping notification email with tracking + // - Update customer portal + // - Start delivery monitoring + } + + /// + /// Handles order cancellation from multiple states. + /// Can be triggered from Draft, Submitted, or Paid states. + /// + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + private void OnCancel() + { + Console.WriteLine($" >> Transition: Cancelling order {Id}"); + // In a real system: + // - Release reserved inventory + // - Cancel payment authorization + // - Log cancellation reason + } + + /// + /// Entry hook executed when entering the Cancelled state. + /// Processes refunds and cleanup. + /// + [StateEntry(OrderState.Cancelled)] + private void OnEnterCancelled() + { + Console.WriteLine($" >> Entry Hook: Order {Id} is cancelled, processing refund if needed"); + // In a real system: + // - Issue refund if payment was processed + // - Send cancellation confirmation + // - Update analytics + } + + #endregion + + /// + /// Gets a human-readable description of the current state. + /// + public string GetStateDescription() + { + return State switch + { + OrderState.Draft => "Order is being prepared", + OrderState.Submitted => "Order is waiting for payment", + OrderState.Paid => "Payment received, preparing for shipment", + OrderState.Shipped => "Order is on its way to you", + OrderState.Cancelled => "Order has been cancelled", + _ => "Unknown state" + }; + } + + /// + /// Gets all triggers that are valid for the current state. + /// + public IEnumerable GetAvailableTriggers() + { + foreach (OrderTrigger trigger in Enum.GetValues(typeof(OrderTrigger))) + { + if (CanFire(trigger)) + { + yield return trigger; + } + } + } +} + +/// +/// Example of a more complex state machine with additional business logic. +/// Demonstrates a document approval workflow. +/// +public enum DocumentState +{ + Draft, + PendingReview, + Approved, + Rejected, + Published, + Archived +} + +public enum DocumentAction +{ + SubmitForReview, + Approve, + Reject, + Revise, + Publish, + Archive +} + +/// +/// Document approval workflow state machine. +/// +[StateMachine(typeof(DocumentState), typeof(DocumentAction))] +public partial class DocumentWorkflow +{ + public string DocumentId { get; } + public string Author { get; } + public List ReviewComments { get; } = new(); + + public DocumentWorkflow(string documentId, string author) + { + DocumentId = documentId; + Author = author; + State = DocumentState.Draft; + } + + [StateTransition(From = DocumentState.Draft, Trigger = DocumentAction.SubmitForReview, To = DocumentState.PendingReview)] + private void OnSubmitForReview() + { + Console.WriteLine($"Document {DocumentId} submitted for review by {Author}"); + } + + [StateGuard(From = DocumentState.PendingReview, Trigger = DocumentAction.Approve)] + private bool CanApprove() + { + // Business rule: Must have at least one review comment + return ReviewComments.Count > 0; + } + + [StateTransition(From = DocumentState.PendingReview, Trigger = DocumentAction.Approve, To = DocumentState.Approved)] + private void OnApprove() + { + Console.WriteLine($"Document {DocumentId} approved"); + } + + [StateTransition(From = DocumentState.PendingReview, Trigger = DocumentAction.Reject, To = DocumentState.Rejected)] + private void OnReject() + { + Console.WriteLine($"Document {DocumentId} rejected"); + } + + [StateTransition(From = DocumentState.Rejected, Trigger = DocumentAction.Revise, To = DocumentState.Draft)] + private void OnRevise() + { + ReviewComments.Clear(); + Console.WriteLine($"Document {DocumentId} sent back to draft for revision"); + } + + [StateTransition(From = DocumentState.Approved, Trigger = DocumentAction.Publish, To = DocumentState.Published)] + private async ValueTask OnPublishAsync(CancellationToken ct) + { + Console.WriteLine($"Publishing document {DocumentId}..."); + await Task.Delay(300, ct); // Simulate publishing + Console.WriteLine($"Document {DocumentId} published"); + } + + [StateTransition(From = DocumentState.Published, Trigger = DocumentAction.Archive, To = DocumentState.Archived)] + private void OnArchive() + { + Console.WriteLine($"Document {DocumentId} archived"); + } +} diff --git a/src/PatternKit.Examples/Generators/State/README.md b/src/PatternKit.Examples/Generators/State/README.md new file mode 100644 index 0000000..8f4a8a8 --- /dev/null +++ b/src/PatternKit.Examples/Generators/State/README.md @@ -0,0 +1,559 @@ +# State Machine Pattern Examples + +This directory contains real-world examples demonstrating the State Machine Pattern Source Generator in action. + +## Overview + +The State Machine Pattern Generator creates deterministic finite state machines with: +- ✅ Explicit states and triggers (enum-based) +- ✅ Compile-time validation +- ✅ Guards for conditional transitions +- ✅ Entry/Exit lifecycle hooks +- ✅ Sync and async support (ValueTask) +- ✅ Zero runtime dependencies + +## Examples in This Directory + +### OrderFlowDemo.cs + +A comprehensive order processing workflow demonstrating: + +1. **Basic State Transitions** - Order lifecycle from Draft to Delivered +2. **Guards** - Payment validation based on amount +3. **Async Transitions** - Payment processing with async/await +4. **Entry/Exit Hooks** - Notifications and cleanup actions +5. **Multiple Transition Sources** - Cancellation from multiple states +6. **Error Handling** - Guard failures and invalid triggers + +#### States +- `Draft` - Initial state when order is created +- `Submitted` - Order submitted for processing +- `Paid` - Payment successfully processed +- `Shipped` - Order shipped to customer +- `Cancelled` - Order cancelled + +#### Triggers +- `Submit` - Submit order for processing +- `Pay` - Process payment +- `Ship` - Ship the order +- `Cancel` - Cancel the order + +#### Running the Demo + +```csharp +using PatternKit.Examples.Generators.State; + +// Happy path: Draft -> Submitted -> Paid -> Shipped +OrderFlowDemo.Run(); + +// Cancellation scenario +OrderFlowDemo.CancellationDemo(); + +// Guard failure scenario +OrderFlowDemo.GuardFailureDemo(); +``` + +#### Sample Output + +``` +=== Order Flow State Machine Demo === + +Order: ORD-001, Amount: $299.99 +Initial State: Draft + +1. Submitting order... + >> Transition: Submitting order ORD-001 + State: Submitted + +2. Attempting to pay... +3. Processing payment... + >> Transition: Processing payment for ORD-001... + >> Payment of $299.99 processed + State: Paid + +4. Shipping order... + >> Exit Hook: Finalizing payment for ORD-001 + >> Transition: Shipping order ORD-001 + >> Entry Hook: Order ORD-001 is now shipped, sending notification + State: Shipped + +=== Order processing complete === +``` + +## Key Concepts Demonstrated + +### 1. Synchronous Transitions + +Simple state changes with synchronous actions: + +```csharp +[StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] +private void OnSubmit() +{ + Console.WriteLine($" >> Transition: Submitting order {Id}"); +} +``` + +### 2. Asynchronous Transitions + +Async operations with proper cancellation token handling: + +```csharp +[StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] +private async ValueTask OnPayAsync(CancellationToken ct) +{ + Console.WriteLine($" >> Transition: Processing payment for {Id}..."); + await Task.Delay(500, ct); // Simulate payment processing + Console.WriteLine($" >> Payment of ${Amount:F2} processed"); +} +``` + +### 3. Guards for Validation + +Prevent invalid transitions based on business rules: + +```csharp +[StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] +private bool CanPay() +{ + return Amount > 0; // Only allow payment for valid amounts +} +``` + +**Usage:** +```csharp +if (order.CanFire(OrderTrigger.Pay)) +{ + await order.FireAsync(OrderTrigger.Pay, CancellationToken.None); +} +else +{ + Console.WriteLine(" Payment blocked by guard (invalid amount)"); +} +``` + +### 4. Entry and Exit Hooks + +Execute side effects when entering or leaving states: + +```csharp +// Exit hook - runs before leaving Paid state +[StateExit(OrderState.Paid)] +private void OnExitPaid() +{ + Console.WriteLine($" >> Exit Hook: Finalizing payment for {Id}"); +} + +// Entry hook - runs after entering Shipped state +[StateEntry(OrderState.Shipped)] +private void OnEnterShipped() +{ + Console.WriteLine($" >> Entry Hook: Order {Id} is now shipped, sending notification"); +} +``` + +**Execution Order (when transitioning Paid -> Shipped):** +1. `OnExitPaid()` - Exit hook +2. `OnShip()` - Transition action +3. `State = Shipped` - State update +4. `OnEnterShipped()` - Entry hook + +### 5. Multiple Transitions from Same Source + +Handle common actions like cancellation from multiple states: + +```csharp +[StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] +[StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] +[StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] +private void OnCancel() +{ + Console.WriteLine($" >> Transition: Cancelling order {Id}"); +} +``` + +## Usage Patterns + +### Pattern 1: Check Before Fire + +Use `CanFire` to check if a trigger is valid: + +```csharp +if (order.CanFire(OrderTrigger.Pay)) +{ + order.Fire(OrderTrigger.Pay); +} +else +{ + Console.WriteLine("Cannot process payment at this time"); +} +``` + +### Pattern 2: Async Workflows + +Use `FireAsync` for async transitions: + +```csharp +try +{ + await order.FireAsync(OrderTrigger.Pay, cancellationToken); + Console.WriteLine("Payment processed successfully"); +} +catch (OperationCanceledException) +{ + Console.WriteLine("Payment cancelled"); +} +catch (InvalidOperationException ex) +{ + Console.WriteLine($"Invalid transition: {ex.Message}"); +} +``` + +### Pattern 3: State-Based Logic + +Make decisions based on current state: + +```csharp +switch (order.State) +{ + case OrderState.Draft: + Console.WriteLine("Order is still being prepared"); + break; + case OrderState.Submitted: + Console.WriteLine("Waiting for payment"); + break; + case OrderState.Shipped: + Console.WriteLine("Order is on its way!"); + break; +} +``` + +### Pattern 4: Transition History + +Track state changes by wrapping Fire methods: + +```csharp +public class TrackedOrderFlow +{ + private readonly OrderFlow _flow; + private readonly List<(OrderState From, OrderTrigger Trigger, OrderState To)> _history = new(); + + public void Fire(OrderTrigger trigger) + { + var from = _flow.State; + _flow.Fire(trigger); + var to = _flow.State; + _history.Add((from, trigger, to)); + } + + public IReadOnlyList<(OrderState From, OrderTrigger Trigger, OrderState To)> History => _history; +} +``` + +## Common Scenarios + +### Scenario 1: Happy Path Processing + +```csharp +var order = new OrderFlow("ORD-001", 299.99m); + +// Draft -> Submitted +order.Fire(OrderTrigger.Submit); + +// Submitted -> Paid +await order.FireAsync(OrderTrigger.Pay, ct); + +// Paid -> Shipped +order.Fire(OrderTrigger.Ship); + +Console.WriteLine($"Order completed in state: {order.State}"); +``` + +### Scenario 2: Validation Failure + +```csharp +var order = new OrderFlow("ORD-002", -50m); // Invalid amount +order.Fire(OrderTrigger.Submit); + +// Guard will prevent payment +if (!order.CanFire(OrderTrigger.Pay)) +{ + Console.WriteLine("Cannot process payment - validation failed"); + order.Fire(OrderTrigger.Cancel); +} +``` + +### Scenario 3: Cancellation + +```csharp +var order = new OrderFlow("ORD-003", 100m); +order.Fire(OrderTrigger.Submit); + +// Customer changes mind before payment +order.Fire(OrderTrigger.Cancel); + +Console.WriteLine($"Order cancelled in state: {order.State}"); +``` + +### Scenario 4: Async with Cancellation + +```csharp +var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); +var order = new OrderFlow("ORD-004", 500m); + +try +{ + order.Fire(OrderTrigger.Submit); + await order.FireAsync(OrderTrigger.Pay, cts.Token); + Console.WriteLine("Payment processed before timeout"); +} +catch (OperationCanceledException) +{ + Console.WriteLine("Payment processing timed out"); + order.Fire(OrderTrigger.Cancel); +} +``` + +## Testing Your State Machines + +### Unit Test Example + +```csharp +using Xunit; + +public class OrderFlowTests +{ + [Fact] + public void Submit_TransitionsFromDraftToSubmitted() + { + // Arrange + var order = new OrderFlow("TEST-001", 100m); + Assert.Equal(OrderState.Draft, order.State); + + // Act + order.Fire(OrderTrigger.Submit); + + // Assert + Assert.Equal(OrderState.Submitted, order.State); + } + + [Fact] + public void Pay_WithZeroAmount_BlockedByGuard() + { + // Arrange + var order = new OrderFlow("TEST-002", 0m); + order.Fire(OrderTrigger.Submit); + + // Assert + Assert.False(order.CanFire(OrderTrigger.Pay)); + } + + [Fact] + public async Task PayAsync_ProcessesPaymentAndTransitionsToPaid() + { + // Arrange + var order = new OrderFlow("TEST-003", 250m); + order.Fire(OrderTrigger.Submit); + + // Act + await order.FireAsync(OrderTrigger.Pay, CancellationToken.None); + + // Assert + Assert.Equal(OrderState.Paid, order.State); + } + + [Fact] + public void Ship_FromDraft_ThrowsInvalidOperationException() + { + // Arrange + var order = new OrderFlow("TEST-004", 100m); + + // Act & Assert + Assert.Throws(() => + order.Fire(OrderTrigger.Ship)); + } +} +``` + +### Integration Test Example + +```csharp +public class OrderFlowIntegrationTests +{ + [Fact] + public async Task CompleteOrderWorkflow_ProcessesSuccessfully() + { + // Arrange + var order = new OrderFlow("INT-001", 199.99m); + var states = new List(); + + // Act + states.Add(order.State); // Draft + + order.Fire(OrderTrigger.Submit); + states.Add(order.State); // Submitted + + await order.FireAsync(OrderTrigger.Pay, CancellationToken.None); + states.Add(order.State); // Paid + + order.Fire(OrderTrigger.Ship); + states.Add(order.State); // Shipped + + // Assert + Assert.Equal(new[] + { + OrderState.Draft, + OrderState.Submitted, + OrderState.Paid, + OrderState.Shipped + }, states); + } +} +``` + +## Best Practices + +### 1. Initialize State in Constructor + +Always set the initial state explicitly: + +```csharp +public OrderFlow(string id, decimal amount) +{ + Id = id; + Amount = amount; + State = OrderState.Draft; // Explicit initial state +} +``` + +### 2. Use Meaningful Names + +Choose clear, business-oriented names: + +```csharp +// Good +public enum OrderState { Draft, Submitted, Paid, Shipped } +public enum OrderTrigger { Submit, Pay, Ship } + +// Avoid +public enum State { S1, S2, S3, S4 } +public enum Action { A1, A2, A3 } +``` + +### 3. Keep Transition Methods Focused + +Each method should have a single responsibility: + +```csharp +// Good - focused on payment +[StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] +private async ValueTask OnPayAsync(CancellationToken ct) +{ + await ProcessPaymentAsync(ct); +} + +// Bad - mixing concerns +[StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] +private async ValueTask OnPayAsync(CancellationToken ct) +{ + await ProcessPaymentAsync(ct); + await SendEmailAsync(ct); // Should be in entry hook + await UpdateInventoryAsync(ct); // Should be separate +} +``` + +### 4. Use Entry/Exit Hooks for Side Effects + +Separate concerns using hooks: + +```csharp +[StateExit(OrderState.Paid)] +private void OnExitPaid() +{ + // Cleanup, finalization + FinalizePaymentRecords(); +} + +[StateEntry(OrderState.Shipped)] +private async ValueTask OnEnterShippedAsync(CancellationToken ct) +{ + // Side effects when entering state + await SendShippingNotificationAsync(ct); + await UpdateInventoryAsync(ct); +} +``` + +### 5. Document Complex Workflows + +Add comments explaining business logic: + +```csharp +/// +/// Processes payment and transitions to Paid state. +/// Business Rules: +/// - Amount must be greater than 0 +/// - Amount must be less than $10,000 (daily limit) +/// - Payment method must be valid +/// +[StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] +private bool CanPay() +{ + return Amount > 0 && Amount < 10000 && IsPaymentMethodValid(); +} +``` + +## Troubleshooting + +### Issue: Guard always returns false in CanFire + +**Problem:** Async guard evaluated synchronously + +**Solution:** Async guards use `GetAwaiter().GetResult()` in `CanFire`. Use `FireAsync` for proper async evaluation. + +### Issue: State doesn't change after Fire + +**Possible causes:** +1. Guard returned false +2. Invalid trigger for current state +3. Check error handling policy + +**Debug:** +```csharp +if (order.CanFire(trigger)) +{ + try + { + order.Fire(trigger); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Transition failed: {ex.Message}"); + } +} +``` + +### Issue: Compilation error about missing partial keyword + +**Solution:** Ensure your class is marked as `partial`: + +```csharp +[StateMachine(typeof(State), typeof(Trigger))] +public partial class MyStateMachine // Add 'partial' +{ +} +``` + +## Further Reading + +- [State Machine Generator Documentation](../../docs/generators/state-machine.md) +- [Generator Diagnostics](../../docs/generators/troubleshooting.md) +- [Pattern Overview](../../docs/patterns/behavioral/state/index.md) + +## Contributing + +Have an interesting state machine example? Submit a PR with: +1. Clear business scenario description +2. State and trigger definitions +3. Complete, runnable example +4. Expected output +5. Key concepts demonstrated diff --git a/src/PatternKit.Generators.Abstractions/State/StateEntryAttribute.cs b/src/PatternKit.Generators.Abstractions/State/StateEntryAttribute.cs new file mode 100644 index 0000000..1c0ef07 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/State/StateEntryAttribute.cs @@ -0,0 +1,25 @@ +namespace PatternKit.Generators.State; + +/// +/// Marks a method to be invoked when entering a specific state. +/// Entry hooks are executed after the State property is updated to the new state +/// and after the transition action method has been called. +/// The method can be synchronous (void) or asynchronous (ValueTask). +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class StateEntryAttribute : Attribute +{ + /// + /// The state for which this entry hook applies. + /// + public object State { get; set; } = null!; + + /// + /// Initializes a new instance of the class. + /// + /// The state for which this entry hook applies. + public StateEntryAttribute(object state) + { + State = state; + } +} diff --git a/src/PatternKit.Generators.Abstractions/State/StateExitAttribute.cs b/src/PatternKit.Generators.Abstractions/State/StateExitAttribute.cs new file mode 100644 index 0000000..cda5ccb --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/State/StateExitAttribute.cs @@ -0,0 +1,24 @@ +namespace PatternKit.Generators.State; + +/// +/// Marks a method to be invoked when exiting a specific state. +/// Exit hooks are executed before the transition action method and before the State property is updated. +/// The method can be synchronous (void) or asynchronous (ValueTask). +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class StateExitAttribute : Attribute +{ + /// + /// The state for which this exit hook applies. + /// + public object State { get; set; } = null!; + + /// + /// Initializes a new instance of the class. + /// + /// The state for which this exit hook applies. + public StateExitAttribute(object state) + { + State = state; + } +} diff --git a/src/PatternKit.Generators.Abstractions/State/StateGuardAttribute.cs b/src/PatternKit.Generators.Abstractions/State/StateGuardAttribute.cs new file mode 100644 index 0000000..c5e558a --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/State/StateGuardAttribute.cs @@ -0,0 +1,21 @@ +namespace PatternKit.Generators.State; + +/// +/// Marks a method as a guard condition for a state transition. +/// The method must return bool or ValueTask<bool> and is evaluated +/// before the transition occurs. If the guard returns false, the transition +/// is prevented according to the GuardFailurePolicy. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class StateGuardAttribute : Attribute +{ + /// + /// The state from which this guard applies. + /// + public object From { get; set; } = null!; + + /// + /// The trigger for which this guard applies. + /// + public object Trigger { get; set; } = null!; +} diff --git a/src/PatternKit.Generators.Abstractions/State/StateMachineAttribute.cs b/src/PatternKit.Generators.Abstractions/State/StateMachineAttribute.cs new file mode 100644 index 0000000..e5e5465 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/State/StateMachineAttribute.cs @@ -0,0 +1,111 @@ +namespace PatternKit.Generators.State; + +/// +/// Marks a partial type as a state machine host that will generate Fire/FireAsync/CanFire methods +/// for deterministic state transitions based on annotated transition, guard, and hook methods. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class StateMachineAttribute : Attribute +{ + /// + /// The state enum type (must be an enum in v1). + /// + public Type StateType { get; } + + /// + /// The trigger enum type (must be an enum in v1). + /// + public Type TriggerType { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The state enum type. + /// The trigger enum type. + public StateMachineAttribute(Type stateType, Type triggerType) + { + StateType = stateType; + TriggerType = triggerType; + } + + /// + /// Gets or sets the name of the generated Fire method. Default: "Fire". + /// + public string FireMethodName { get; set; } = "Fire"; + + /// + /// Gets or sets the name of the generated FireAsync method. Default: "FireAsync". + /// + public string FireAsyncMethodName { get; set; } = "FireAsync"; + + /// + /// Gets or sets the name of the generated CanFire method. Default: "CanFire". + /// + public string CanFireMethodName { get; set; } = "CanFire"; + + /// + /// Gets or sets whether to generate async methods. + /// When not specified, async generation is inferred from the presence of async transitions/hooks. + /// + public bool GenerateAsync { get; set; } + + /// + /// Gets or sets whether to force async generation even if all transitions/hooks are synchronous. + /// Default is false. + /// + public bool ForceAsync { get; set; } + + /// + /// Gets or sets the policy for handling invalid triggers. + /// Default is Throw. + /// + public StateMachineInvalidTriggerPolicy InvalidTrigger { get; set; } = StateMachineInvalidTriggerPolicy.Throw; + + /// + /// Gets or sets the policy for handling guard failures. + /// Default is Throw. + /// + public StateMachineGuardFailurePolicy GuardFailure { get; set; } = StateMachineGuardFailurePolicy.Throw; +} + +/// +/// Defines the policy for handling invalid triggers. +/// +public enum StateMachineInvalidTriggerPolicy +{ + /// + /// Throw an InvalidOperationException when an invalid trigger is fired. + /// + Throw = 0, + + /// + /// Ignore invalid triggers (no-op). + /// + Ignore = 1, + + /// + /// Return false from CanFire and no-op in Fire for invalid triggers. + /// + ReturnFalse = 2 +} + +/// +/// Defines the policy for handling guard failures. +/// +public enum StateMachineGuardFailurePolicy +{ + /// + /// Throw an InvalidOperationException when a guard returns false. + /// + Throw = 0, + + /// + /// Ignore guard failures (no-op, state does not transition). + /// + Ignore = 1, + + /// + /// Return false from CanFire and no-op in Fire when guard fails. + /// + ReturnFalse = 2 +} diff --git a/src/PatternKit.Generators.Abstractions/State/StateTransitionAttribute.cs b/src/PatternKit.Generators.Abstractions/State/StateTransitionAttribute.cs new file mode 100644 index 0000000..63bdc3c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/State/StateTransitionAttribute.cs @@ -0,0 +1,25 @@ +namespace PatternKit.Generators.State; + +/// +/// Marks a method to be invoked during a specific state transition. +/// The method can be synchronous (void/ValueTask) and is executed between +/// exit hooks (old state) and entry hooks (new state). +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class StateTransitionAttribute : Attribute +{ + /// + /// The state from which this transition originates. + /// + public object From { get; set; } = null!; + + /// + /// The trigger that activates this transition. + /// + public object Trigger { get; set; } = null!; + + /// + /// The state to which this transition leads. + /// + public object To { get; set; } = null!; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index ce3dcad..65ad3e8 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -116,3 +116,13 @@ PKADP015 | PatternKit.Generators.Adapter | Error | Mapping method must be access PKADP016 | PatternKit.Generators.Adapter | Error | Static members are not supported PKADP017 | PatternKit.Generators.Adapter | Error | Ref-return members are not supported PKADP018 | PatternKit.Generators.Adapter | Error | Indexers are not supported +PKST001 | PatternKit.Generators.State | Error | Type marked with [StateMachine] must be partial +PKST002 | PatternKit.Generators.State | Error | State type must be an enum +PKST003 | PatternKit.Generators.State | Error | Trigger type must be an enum +PKST004 | PatternKit.Generators.State | Error | Duplicate transition detected +PKST005 | PatternKit.Generators.State | Error | Transition method signature invalid +PKST006 | PatternKit.Generators.State | Error | Guard method signature invalid +PKST007 | PatternKit.Generators.State | Error | Entry/Exit hook signature invalid +PKST008 | PatternKit.Generators.State | Warning | Async method detected but async generation disabled +PKST009 | PatternKit.Generators.State | Error | Generic types not supported for State pattern +PKST010 | PatternKit.Generators.State | Error | Nested types not supported for State pattern diff --git a/src/PatternKit.Generators/StateMachineGenerator.cs b/src/PatternKit.Generators/StateMachineGenerator.cs new file mode 100644 index 0000000..a35b208 --- /dev/null +++ b/src/PatternKit.Generators/StateMachineGenerator.cs @@ -0,0 +1,1069 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Text; + +namespace PatternKit.Generators; + +/// +/// Source generator for the State pattern. +/// Generates deterministic state machine implementations with explicit states, triggers, +/// guards, entry/exit hooks, and sync/async support using ValueTask. +/// +[Generator] +public sealed class StateMachineGenerator : IIncrementalGenerator +{ + // Diagnostic IDs + private const string DiagIdTypeNotPartial = "PKST001"; + private const string DiagIdStateNotEnum = "PKST002"; + private const string DiagIdTriggerNotEnum = "PKST003"; + private const string DiagIdDuplicateTransition = "PKST004"; + private const string DiagIdInvalidTransitionSignature = "PKST005"; + private const string DiagIdInvalidGuardSignature = "PKST006"; + private const string DiagIdInvalidHookSignature = "PKST007"; + private const string DiagIdAsyncMethodDetected = "PKST008"; + private const string DiagIdGenericTypeNotSupported = "PKST009"; + private const string DiagIdNestedTypeNotSupported = "PKST010"; + + private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( + id: DiagIdTypeNotPartial, + title: "Type marked with [StateMachine] must be partial", + messageFormat: "Type '{0}' is marked with [StateMachine] but is not declared as partial. Add the 'partial' keyword to the type declaration.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor StateNotEnumDescriptor = new( + id: DiagIdStateNotEnum, + title: "State type must be an enum", + messageFormat: "State type '{0}' must be an enum type. Non-enum state types are not supported in v1.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor TriggerNotEnumDescriptor = new( + id: DiagIdTriggerNotEnum, + title: "Trigger type must be an enum", + messageFormat: "Trigger type '{0}' must be an enum type. Non-enum trigger types are not supported in v1.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateTransitionDescriptor = new( + id: DiagIdDuplicateTransition, + title: "Duplicate transition detected", + messageFormat: "Duplicate transition detected for (From={0}, Trigger={1}). Each (From, Trigger) pair must be unique. Conflicting methods: {2}.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidTransitionSignatureDescriptor = new( + id: DiagIdInvalidTransitionSignature, + title: "Transition method signature invalid", + messageFormat: "Transition method '{0}' has an invalid signature. Transitions must return void or ValueTask, optionally accepting CancellationToken for async methods.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidGuardSignatureDescriptor = new( + id: DiagIdInvalidGuardSignature, + title: "Guard method signature invalid", + messageFormat: "Guard method '{0}' has an invalid signature. Guards must return bool or ValueTask, optionally accepting CancellationToken for async methods.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidHookSignatureDescriptor = new( + id: DiagIdInvalidHookSignature, + title: "Entry/Exit hook signature invalid", + messageFormat: "Entry/Exit hook method '{0}' has an invalid signature. Hooks must return void or ValueTask, optionally accepting CancellationToken for async methods.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AsyncMethodDetectedDescriptor = new( + id: DiagIdAsyncMethodDetected, + title: "Async method detected but async generation disabled", + messageFormat: "Async method '{0}' detected but async generation is disabled. Enable GenerateAsync or ForceAsync on the [StateMachine] attribute.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor GenericTypeNotSupportedDescriptor = new( + id: DiagIdGenericTypeNotSupported, + title: "Generic types not supported for State pattern", + messageFormat: "Type '{0}' is generic, which is not currently supported by the StateMachine generator. Remove the [StateMachine] attribute or use a non-generic type.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NestedTypeNotSupportedDescriptor = new( + id: DiagIdNestedTypeNotSupported, + title: "Nested types not supported for State pattern", + messageFormat: "Type '{0}' is nested inside another type, which is not currently supported by the StateMachine generator. Remove the [StateMachine] attribute or move the type to the top level.", + category: "PatternKit.Generators.State", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all type declarations with [StateMachine] attribute + var stateMachineTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.State.StateMachineAttribute", + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + // Generate for each type + context.RegisterSourceOutput(stateMachineTypes, (spc, typeContext) => + { + if (typeContext.TargetSymbol is not INamedTypeSymbol typeSymbol) + return; + + var attr = typeContext.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.State.StateMachineAttribute"); + if (attr is null) + return; + + GenerateStateMachineForType(spc, typeSymbol, attr, typeContext.TargetNode); + }); + } + + private void GenerateStateMachineForType( + SourceProductionContext context, + INamedTypeSymbol typeSymbol, + AttributeData attribute, + SyntaxNode node) + { + // Check if type is partial + if (!IsPartialType(node)) + { + context.ReportDiagnostic(Diagnostic.Create( + TypeNotPartialDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Check for generic types + if (typeSymbol.IsGenericType) + { + context.ReportDiagnostic(Diagnostic.Create( + GenericTypeNotSupportedDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Check for nested types + if (typeSymbol.ContainingType is not null) + { + context.ReportDiagnostic(Diagnostic.Create( + NestedTypeNotSupportedDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Parse attribute configuration + var config = ParseStateMachineConfig(attribute, context, out var stateType, out var triggerType); + if (config is null || stateType is null || triggerType is null) + return; + + // Validate state and trigger types are enums + if (stateType.TypeKind != TypeKind.Enum) + { + context.ReportDiagnostic(Diagnostic.Create( + StateNotEnumDescriptor, + node.GetLocation(), + stateType.ToDisplayString())); + return; + } + + if (triggerType.TypeKind != TypeKind.Enum) + { + context.ReportDiagnostic(Diagnostic.Create( + TriggerNotEnumDescriptor, + node.GetLocation(), + triggerType.ToDisplayString())); + return; + } + + // Collect transitions, guards, and hooks + var transitions = CollectTransitions(typeSymbol, stateType, triggerType, context); + var guards = CollectGuards(typeSymbol, stateType, triggerType, context); + var entryHooks = CollectEntryHooks(typeSymbol, stateType, context); + var exitHooks = CollectExitHooks(typeSymbol, stateType, context); + + // Validate for duplicate transitions + if (!ValidateTransitions(transitions, typeSymbol, context)) + return; + + // Validate signatures + if (!ValidateSignatures(transitions, guards, entryHooks, exitHooks, context)) + return; + + // Determine if async generation is needed + var hasAsyncMembers = DetermineIfAsync(transitions, guards, entryHooks, exitHooks); + bool needsAsync; + + // Check if GenerateAsync was explicitly set to false and async members exist + var explicitlyDisabled = config.GenerateAsyncExplicitlySet && + config.GenerateAsync.HasValue && + !config.GenerateAsync.Value; + + if (explicitlyDisabled && hasAsyncMembers && !config.ForceAsync) + { + // Async members are present but async generation was explicitly disabled. + // Emit PKST008 and avoid generating FireAsync, while still allowing sync + // operations to block on async members as per the specification. + context.ReportDiagnostic(Diagnostic.Create( + AsyncMethodDetectedDescriptor, + node.GetLocation(), + "async transitions/guards/hooks")); + needsAsync = false; + } + else + { + // If ForceAsync is set, always generate async APIs. + // Otherwise, honor an explicit GenerateAsync value when present, + // falling back to generating async APIs only when async members exist. + needsAsync = config.ForceAsync || + (config.GenerateAsync ?? hasAsyncMembers); + } + + // Generate the state machine implementation + var source = GenerateStateMachine(typeSymbol, config, stateType, triggerType, + transitions, guards, entryHooks, exitHooks, needsAsync); + var fileName = $"{typeSymbol.Name}.StateMachine.g.cs"; + context.AddSource(fileName, source); + } + + private static bool IsPartialType(SyntaxNode node) + { + return node switch + { + ClassDeclarationSyntax classDecl => classDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + StructDeclarationSyntax structDecl => structDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + RecordDeclarationSyntax recordDecl => recordDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + _ => false + }; + } + + private StateMachineConfig? ParseStateMachineConfig( + AttributeData attribute, + SourceProductionContext context, + out ITypeSymbol? stateType, + out ITypeSymbol? triggerType) + { + stateType = null; + triggerType = null; + + // Constructor arguments: stateType, triggerType + if (attribute.ConstructorArguments.Length < 2) + return null; + + stateType = attribute.ConstructorArguments[0].Value as ITypeSymbol; + triggerType = attribute.ConstructorArguments[1].Value as ITypeSymbol; + + if (stateType is null || triggerType is null) + return null; + + var config = new StateMachineConfig + { + StateTypeName = stateType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + TriggerTypeName = triggerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + foreach (var namedArg in attribute.NamedArguments) + { + switch (namedArg.Key) + { + case "FireMethodName": + config.FireMethodName = namedArg.Value.Value?.ToString() ?? "Fire"; + break; + case "FireAsyncMethodName": + config.FireAsyncMethodName = namedArg.Value.Value?.ToString() ?? "FireAsync"; + break; + case "CanFireMethodName": + config.CanFireMethodName = namedArg.Value.Value?.ToString() ?? "CanFire"; + break; + case "GenerateAsync": + if (namedArg.Value.Value is bool ga) + { + config.GenerateAsync = ga; + config.GenerateAsyncExplicitlySet = true; + } + break; + case "ForceAsync": + config.ForceAsync = namedArg.Value.Value is bool f && f; + break; + case "InvalidTrigger": + config.InvalidTriggerPolicy = namedArg.Value.Value is int itp ? itp : 0; + break; + case "GuardFailure": + config.GuardFailurePolicy = namedArg.Value.Value is int gfp ? gfp : 0; + break; + } + } + + return config; + } + + private ImmutableArray CollectTransitions( + INamedTypeSymbol typeSymbol, + ITypeSymbol stateType, + ITypeSymbol triggerType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var method in typeSymbol.GetMembers().OfType()) + { + var transitionAttrs = method.GetAttributes().Where(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.State.StateTransitionAttribute"); + + foreach (var transitionAttr in transitionAttrs) + { + string? fromState = null; + string? trigger = null; + string? toState = null; + + foreach (var namedArg in transitionAttr.NamedArguments) + { + if (namedArg.Key == "From") + fromState = GetEnumValueName(namedArg.Value, stateType); + else if (namedArg.Key == "Trigger") + trigger = GetEnumValueName(namedArg.Value, triggerType); + else if (namedArg.Key == "To") + toState = GetEnumValueName(namedArg.Value, stateType); + } + + if (fromState is not null && trigger is not null && toState is not null) + { + builder.Add(new TransitionModel + { + Method = method, + FromState = fromState, + Trigger = trigger, + ToState = toState + }); + } + } + } + + return builder.ToImmutable(); + } + + private ImmutableArray CollectGuards( + INamedTypeSymbol typeSymbol, + ITypeSymbol stateType, + ITypeSymbol triggerType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var method in typeSymbol.GetMembers().OfType()) + { + var guardAttrs = method.GetAttributes().Where(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.State.StateGuardAttribute"); + + foreach (var guardAttr in guardAttrs) + { + string? fromState = null; + string? trigger = null; + + foreach (var namedArg in guardAttr.NamedArguments) + { + if (namedArg.Key == "From") + fromState = GetEnumValueName(namedArg.Value, stateType); + else if (namedArg.Key == "Trigger") + trigger = GetEnumValueName(namedArg.Value, triggerType); + } + + if (fromState is not null && trigger is not null) + { + builder.Add(new GuardModel + { + Method = method, + FromState = fromState, + Trigger = trigger + }); + } + } + } + + return builder.ToImmutable(); + } + + private ImmutableArray CollectEntryHooks( + INamedTypeSymbol typeSymbol, + ITypeSymbol stateType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var method in typeSymbol.GetMembers().OfType()) + { + var entryAttrs = method.GetAttributes().Where(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.State.StateEntryAttribute"); + + foreach (var entryAttr in entryAttrs) + { + string? state = null; + + // Check constructor argument first + if (entryAttr.ConstructorArguments.Length > 0) + { + state = GetEnumValueName(entryAttr.ConstructorArguments[0], stateType); + } + else + { + // Check named argument + var stateArg = entryAttr.NamedArguments.FirstOrDefault(na => na.Key == "State"); + if (stateArg.Key is not null) + state = GetEnumValueName(stateArg.Value, stateType); + } + + if (state is not null) + { + builder.Add(new HookModel + { + Method = method, + State = state + }); + } + } + } + + return builder.ToImmutable(); + } + + private ImmutableArray CollectExitHooks( + INamedTypeSymbol typeSymbol, + ITypeSymbol stateType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var method in typeSymbol.GetMembers().OfType()) + { + var exitAttrs = method.GetAttributes().Where(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.State.StateExitAttribute"); + + foreach (var exitAttr in exitAttrs) + { + string? state = null; + + // Check constructor argument first + if (exitAttr.ConstructorArguments.Length > 0) + { + state = GetEnumValueName(exitAttr.ConstructorArguments[0], stateType); + } + else + { + // Check named argument + var stateArg = exitAttr.NamedArguments.FirstOrDefault(na => na.Key == "State"); + if (stateArg.Key is not null) + state = GetEnumValueName(stateArg.Value, stateType); + } + + if (state is not null) + { + builder.Add(new HookModel + { + Method = method, + State = state + }); + } + } + } + + return builder.ToImmutable(); + } + + private string? GetEnumValueName(TypedConstant constant, ITypeSymbol enumType) + { + if (constant.Value is null) + return null; + + ulong targetValue; + try + { + targetValue = Convert.ToUInt64(constant.Value); + } + catch + { + return null; + } + + foreach (var field in enumType.GetMembers().OfType()) + { + if (!field.IsConst || !field.HasConstantValue || field.ConstantValue is null) + continue; + + try + { + var fieldValue = Convert.ToUInt64(field.ConstantValue); + if (fieldValue == targetValue) + return field.Name; + } + catch + { + // Skip values that cannot be converted to UInt64 + } + } + + return null; + } + + private bool ValidateTransitions( + ImmutableArray transitions, + INamedTypeSymbol typeSymbol, + SourceProductionContext context) + { + var transitionKeys = new Dictionary>(); + + foreach (var transition in transitions) + { + var key = $"{transition.FromState},{transition.Trigger}"; + if (!transitionKeys.ContainsKey(key)) + transitionKeys[key] = new List<(string MethodName, Location Location)>(); + + var methodLocation = transition.Method.Locations.FirstOrDefault() ?? Location.None; + transitionKeys[key].Add((transition.Method.Name, methodLocation)); + } + + var hasDuplicates = false; + + foreach (var kvp in transitionKeys.Where(kvp => kvp.Value.Count > 1)) + { + var parts = kvp.Key.Split(','); + var methodNames = string.Join(", ", kvp.Value.Select(v => v.MethodName)); + + // Prefer a concrete source location from one of the conflicting methods. + var location = kvp.Value + .Select(v => v.Location) + .FirstOrDefault(loc => loc != Location.None) ?? Location.None; + + context.ReportDiagnostic(Diagnostic.Create( + DuplicateTransitionDescriptor, + location, + parts[0], + parts[1], + methodNames)); + + hasDuplicates = true; + } + + return !hasDuplicates; + } + + private bool ValidateSignatures( + ImmutableArray transitions, + ImmutableArray guards, + ImmutableArray entryHooks, + ImmutableArray exitHooks, + SourceProductionContext context) + { + foreach (var transition in transitions) + { + if (!ValidateTransitionSignature(transition.Method, context)) + return false; + } + + foreach (var guard in guards) + { + if (!ValidateGuardSignature(guard.Method, context)) + return false; + } + + foreach (var hook in entryHooks.Concat(exitHooks)) + { + if (!ValidateHookSignature(hook.Method, context)) + return false; + } + + return true; + } + + private bool ValidateTransitionSignature(IMethodSymbol method, SourceProductionContext context) + { + var returnsVoid = method.ReturnsVoid; + var returnType = method.ReturnType; + var returnsValueTask = IsNonGenericValueTask(returnType); + + if (!returnsVoid && !returnsValueTask) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidTransitionSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + // If parameters exist, they must be CancellationToken only + if (method.Parameters.Length > 1 || + (method.Parameters.Length == 1 && !IsCancellationToken(method.Parameters[0].Type))) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidTransitionSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + return true; + } + + private bool ValidateGuardSignature(IMethodSymbol method, SourceProductionContext context) + { + var returnType = method.ReturnType; + var returnsBool = returnType.SpecialType == SpecialType.System_Boolean; + var returnsValueTaskBool = IsGenericValueTaskOfBool(returnType); + + if (!returnsBool && !returnsValueTaskBool) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidGuardSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + // If parameters exist, they must be CancellationToken only + if (method.Parameters.Length > 1 || + (method.Parameters.Length == 1 && !IsCancellationToken(method.Parameters[0].Type))) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidGuardSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + return true; + } + + private bool ValidateHookSignature(IMethodSymbol method, SourceProductionContext context) + { + var returnsVoid = method.ReturnsVoid; + var returnType = method.ReturnType; + var returnsValueTask = IsNonGenericValueTask(returnType); + + if (!returnsVoid && !returnsValueTask) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidHookSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + // If parameters exist, they must be CancellationToken only + if (method.Parameters.Length > 1 || + (method.Parameters.Length == 1 && !IsCancellationToken(method.Parameters[0].Type))) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidHookSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + return true; + } + + private bool DetermineIfAsync( + ImmutableArray transitions, + ImmutableArray guards, + ImmutableArray entryHooks, + ImmutableArray exitHooks) + { + foreach (var transition in transitions) + { + if (IsNonGenericValueTask(transition.Method.ReturnType)) + return true; + } + + foreach (var guard in guards) + { + if (IsGenericValueTaskOfBool(guard.Method.ReturnType)) + return true; + } + + foreach (var hook in entryHooks.Concat(exitHooks)) + { + if (IsNonGenericValueTask(hook.Method.ReturnType)) + return true; + } + + return false; + } + + private bool IsNonGenericValueTask(ITypeSymbol type) + { + return type is INamedTypeSymbol namedType && + namedType.Name == "ValueTask" && + namedType.Arity == 0 && + namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; + } + + private bool IsGenericValueTaskOfBool(ITypeSymbol type) + { + return type is INamedTypeSymbol namedType && + namedType.Name == "ValueTask" && + namedType.Arity == 1 && + namedType.TypeArguments.Length == 1 && + namedType.TypeArguments[0].SpecialType == SpecialType.System_Boolean && + namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; + } + + private bool IsCancellationToken(ITypeSymbol type) + { + return type.ToDisplayString() == "System.Threading.CancellationToken"; + } + + private string GenerateStateMachine( + INamedTypeSymbol typeSymbol, + StateMachineConfig config, + ITypeSymbol stateType, + ITypeSymbol triggerType, + ImmutableArray transitions, + ImmutableArray guards, + ImmutableArray entryHooks, + ImmutableArray exitHooks, + bool needsAsync) + { + var sb = new StringBuilder(); + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? null + : typeSymbol.ContainingNamespace.ToDisplayString(); + + // File header + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + if (ns is not null) + { + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); + } + + // Get type keyword (class, struct, record class, record struct) + var typeKeyword = GetTypeKeyword(typeSymbol); + + sb.AppendLine($"partial {typeKeyword} {typeSymbol.Name}"); + sb.AppendLine("{"); + + // State property + sb.AppendLine($" /// "); + sb.AppendLine($" /// Gets the current state of the state machine."); + sb.AppendLine($" /// "); + sb.AppendLine($" public {config.StateTypeName} State {{ get; private set; }}"); + sb.AppendLine(); + + // CanFire method + GenerateCanFireMethod(sb, config, transitions, guards, needsAsync); + + // Fire method + GenerateFireMethod(sb, config, stateType, triggerType, transitions, guards, entryHooks, exitHooks, false); + + // FireAsync method (if needed) + if (needsAsync) + { + GenerateFireMethod(sb, config, stateType, triggerType, transitions, guards, entryHooks, exitHooks, true); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private string GetTypeKeyword(INamedTypeSymbol typeSymbol) + { + if (typeSymbol.IsRecord) + { + return typeSymbol.IsValueType ? "record struct" : "record class"; + } + return typeSymbol.IsValueType ? "struct" : "class"; + } + + private void GenerateCanFireMethod( + StringBuilder sb, + StateMachineConfig config, + ImmutableArray transitions, + ImmutableArray guards, + bool needsAsync) + { + sb.AppendLine($" /// "); + sb.AppendLine($" /// Determines whether the specified trigger can be fired from the current state."); + sb.AppendLine($" /// "); + sb.AppendLine($" /// The trigger to check."); + sb.AppendLine($" /// true if the trigger can be fired; otherwise, false."); + sb.AppendLine($" public bool {config.CanFireMethodName}({config.TriggerTypeName} trigger)"); + sb.AppendLine($" {{"); + + // Group transitions by (from, trigger) + var transitionGroups = transitions + .GroupBy(t => (t.FromState, t.Trigger)) + .OrderBy(g => g.Key.FromState, StringComparer.Ordinal) + .ThenBy(g => g.Key.Trigger, StringComparer.Ordinal); + + if (transitionGroups.Any()) + { + sb.AppendLine($" return (State, trigger) switch"); + sb.AppendLine($" {{"); + + foreach (var group in transitionGroups) + { + var (fromState, trigger) = group.Key; + + // Check if there's a guard for this transition + var guard = guards.FirstOrDefault(g => g.FromState == fromState && g.Trigger == trigger); + + if (guard is not null) + { + var guardHasCt = guard.Method.Parameters.Length > 0 && IsCancellationToken(guard.Method.Parameters[0].Type); + + // If guard is async, evaluate it synchronously using GetAwaiter().GetResult() + if (IsGenericValueTaskOfBool(guard.Method.ReturnType)) + { + var guardCall = guardHasCt + ? $"{guard.Method.Name}(global::System.Threading.CancellationToken.None).GetAwaiter().GetResult()" + : $"{guard.Method.Name}().GetAwaiter().GetResult()"; + sb.AppendLine($" ({config.StateTypeName}.{fromState}, {config.TriggerTypeName}.{trigger}) => {guardCall},"); + } + else + { + var guardCall = guardHasCt + ? $"{guard.Method.Name}(global::System.Threading.CancellationToken.None)" + : $"{guard.Method.Name}()"; + sb.AppendLine($" ({config.StateTypeName}.{fromState}, {config.TriggerTypeName}.{trigger}) => {guardCall},"); + } + } + else + { + sb.AppendLine($" ({config.StateTypeName}.{fromState}, {config.TriggerTypeName}.{trigger}) => true,"); + } + } + + sb.AppendLine($" _ => false"); + sb.AppendLine($" }};"); + } + else + { + sb.AppendLine($" return false;"); + } + + sb.AppendLine($" }}"); + sb.AppendLine(); + } + + private void GenerateFireMethod( + StringBuilder sb, + StateMachineConfig config, + ITypeSymbol stateType, + ITypeSymbol triggerType, + ImmutableArray transitions, + ImmutableArray guards, + ImmutableArray entryHooks, + ImmutableArray exitHooks, + bool isAsync) + { + var methodName = isAsync ? config.FireAsyncMethodName : config.FireMethodName; + var returnType = isAsync ? "global::System.Threading.Tasks.ValueTask" : "void"; + var ctParam = isAsync ? ", global::System.Threading.CancellationToken cancellationToken = default" : ""; + var awaitKeyword = isAsync ? "await " : ""; + var asyncModifier = isAsync ? "async " : ""; + var configureAwait = isAsync ? ".ConfigureAwait(false)" : ""; + + sb.AppendLine($" /// "); + sb.AppendLine($" /// Fires the specified trigger, potentially transitioning to a new state."); + sb.AppendLine($" /// "); + sb.AppendLine($" /// The trigger to fire."); + if (isAsync) + { + sb.AppendLine($" /// A cancellation token."); + } + sb.AppendLine($" public {asyncModifier}{returnType} {methodName}({config.TriggerTypeName} trigger{ctParam})"); + sb.AppendLine($" {{"); + + // Group transitions by (from, trigger) + var transitionGroups = transitions + .GroupBy(t => (t.FromState, t.Trigger)) + .OrderBy(g => g.Key.FromState) + .ThenBy(g => g.Key.Trigger) + .ToList(); + + if (transitionGroups.Count == 0) + { + // No transitions defined + if (config.InvalidTriggerPolicy == 0) // Throw + { + sb.AppendLine($" throw new global::System.InvalidOperationException($\"No transitions defined for state machine.\");"); + } + sb.AppendLine($" }}"); + sb.AppendLine(); + return; + } + + sb.AppendLine($" switch (State)"); + sb.AppendLine($" {{"); + + // Group by from state + var stateGroups = transitionGroups.GroupBy(g => g.Key.FromState); + foreach (var stateGroup in stateGroups) + { + var fromState = stateGroup.Key; + sb.AppendLine($" case {config.StateTypeName}.{fromState}:"); + sb.AppendLine($" switch (trigger)"); + sb.AppendLine($" {{"); + + foreach (var triggerGroup in stateGroup) + { + var trigger = triggerGroup.Key.Trigger; + var transition = triggerGroup.First(); // Should only be one after validation + var guard = guards.FirstOrDefault(g => g.FromState == fromState && g.Trigger == trigger); + + sb.AppendLine($" case {config.TriggerTypeName}.{trigger}:"); + + // Evaluate guard if exists + if (guard is not null) + { + var guardHasCt = guard.Method.Parameters.Length > 0 && IsCancellationToken(guard.Method.Parameters[0].Type); + var guardCall = IsGenericValueTaskOfBool(guard.Method.ReturnType) + ? (isAsync + ? (guardHasCt ? $"await {guard.Method.Name}(cancellationToken){configureAwait}" : $"await {guard.Method.Name}(){configureAwait}") + : (guardHasCt ? $"{guard.Method.Name}(global::System.Threading.CancellationToken.None).GetAwaiter().GetResult()" : $"{guard.Method.Name}().GetAwaiter().GetResult()")) + : (guardHasCt + ? (isAsync ? $"{guard.Method.Name}(cancellationToken)" : $"{guard.Method.Name}(global::System.Threading.CancellationToken.None)") + : $"{guard.Method.Name}()"); + + sb.AppendLine($" if (!{guardCall})"); + sb.AppendLine($" {{"); + + if (config.GuardFailurePolicy == 0) // Throw + { + sb.AppendLine($" throw new global::System.InvalidOperationException($\"Guard failed for transition from {fromState} on trigger {trigger}.\");"); + } + else // Ignore or ReturnFalse - both just return + { + sb.AppendLine($" return;"); + } + + sb.AppendLine($" }}"); + } + + // Execute exit hooks for fromState + var exitHooksForState = exitHooks.Where(h => h.State == fromState).ToList(); + foreach (var exitHook in exitHooksForState) + { + var hookHasCt = exitHook.Method.Parameters.Length > 0 && IsCancellationToken(exitHook.Method.Parameters[0].Type); + var hookCall = IsNonGenericValueTask(exitHook.Method.ReturnType) + ? (isAsync + ? (hookHasCt ? $"await {exitHook.Method.Name}(cancellationToken){configureAwait};" : $"await {exitHook.Method.Name}(){configureAwait};") + : (hookHasCt ? $"{exitHook.Method.Name}(global::System.Threading.CancellationToken.None).GetAwaiter().GetResult();" : $"{exitHook.Method.Name}().GetAwaiter().GetResult();")) + : $"{exitHook.Method.Name}();"; + sb.AppendLine($" {hookCall}"); + } + + // Execute transition action + var transitionHasCt = transition.Method.Parameters.Length > 0 && IsCancellationToken(transition.Method.Parameters[0].Type); + if (IsNonGenericValueTask(transition.Method.ReturnType)) + { + var transitionCall = isAsync + ? (transitionHasCt ? $"await {transition.Method.Name}(cancellationToken){configureAwait};" : $"await {transition.Method.Name}(){configureAwait};") + : (transitionHasCt ? $"{transition.Method.Name}(global::System.Threading.CancellationToken.None).GetAwaiter().GetResult();" : $"{transition.Method.Name}().GetAwaiter().GetResult();"); + sb.AppendLine($" {transitionCall}"); + } + else + { + sb.AppendLine($" {transition.Method.Name}();"); + } + + // Update state + sb.AppendLine($" State = {config.StateTypeName}.{transition.ToState};"); + + // Execute entry hooks for toState + var entryHooksForState = entryHooks.Where(h => h.State == transition.ToState).ToList(); + foreach (var entryHook in entryHooksForState) + { + var entryHasCt = entryHook.Method.Parameters.Length > 0 && IsCancellationToken(entryHook.Method.Parameters[0].Type); + var hookCall = IsNonGenericValueTask(entryHook.Method.ReturnType) + ? (isAsync + ? (entryHasCt ? $"await {entryHook.Method.Name}(cancellationToken){configureAwait};" : $"await {entryHook.Method.Name}(){configureAwait};") + : (entryHasCt ? $"{entryHook.Method.Name}(global::System.Threading.CancellationToken.None).GetAwaiter().GetResult();" : $"{entryHook.Method.Name}().GetAwaiter().GetResult();")) + : $"{entryHook.Method.Name}();"; + sb.AppendLine($" {hookCall}"); + } + + sb.AppendLine($" return;"); + } + + // Default case for invalid trigger in this state + sb.AppendLine($" default:"); + if (config.InvalidTriggerPolicy == 0) // Throw + { + sb.AppendLine($" throw new global::System.InvalidOperationException($\"Invalid trigger {{trigger}} for state {fromState}.\");"); + } + else // Ignore or ReturnFalse + { + sb.AppendLine($" return;"); + } + sb.AppendLine($" }}"); + } + + // Default case for states with no transitions + sb.AppendLine($" default:"); + if (config.InvalidTriggerPolicy == 0) // Throw + { + sb.AppendLine($" throw new global::System.InvalidOperationException($\"No transitions defined for state {{State}}.\");"); + } + else // Ignore or ReturnFalse + { + sb.AppendLine($" return;"); + } + + sb.AppendLine($" }}"); + sb.AppendLine($" }}"); + sb.AppendLine(); + } + + private sealed class StateMachineConfig + { + public string StateTypeName { get; set; } = null!; + public string TriggerTypeName { get; set; } = null!; + public string FireMethodName { get; set; } = "Fire"; + public string FireAsyncMethodName { get; set; } = "FireAsync"; + public string CanFireMethodName { get; set; } = "CanFire"; + public bool? GenerateAsync { get; set; } // Null = not set (infer), true = always generate, false = never generate + public bool GenerateAsyncExplicitlySet { get; set; } // Track if GenerateAsync was explicitly set + public bool ForceAsync { get; set; } + public int InvalidTriggerPolicy { get; set; } = 0; // 0=Throw, 1=Ignore, 2=ReturnFalse + public int GuardFailurePolicy { get; set; } = 0; // 0=Throw, 1=Ignore, 2=ReturnFalse + } + + private sealed class TransitionModel + { + public IMethodSymbol Method { get; set; } = null!; + public string FromState { get; set; } = null!; + public string Trigger { get; set; } = null!; + public string ToState { get; set; } = null!; + } + + private sealed class GuardModel + { + public IMethodSymbol Method { get; set; } = null!; + public string FromState { get; set; } = null!; + public string Trigger { get; set; } = null!; + } + + private sealed class HookModel + { + public IMethodSymbol Method { get; set; } = null!; + public string State { get; set; } = null!; + } +} diff --git a/test/PatternKit.Generators.Tests/StateMachineGeneratorTests.cs b/test/PatternKit.Generators.Tests/StateMachineGeneratorTests.cs new file mode 100644 index 0000000..6aa6484 --- /dev/null +++ b/test/PatternKit.Generators.Tests/StateMachineGeneratorTests.cs @@ -0,0 +1,1036 @@ +using Microsoft.CodeAnalysis; + +namespace PatternKit.Generators.Tests; + +public class StateMachineGeneratorTests +{ + [Fact] + public void BasicStateMachine_Class_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid, Shipped, Cancelled } + public enum OrderTrigger { Submit, Pay, Ship, Cancel } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private void OnPay() { } + + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Ship, To = OrderState.Shipped)] + private void OnShip() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(BasicStateMachine_Class_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Confirm we generated the expected file + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("OrderFlow.StateMachine.g.cs", names); + + // Verify the generated source contains expected members + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("public global::PatternKit.Examples.OrderState State { get; private set; }", generatedSource); + Assert.Contains("public bool CanFire(global::PatternKit.Examples.OrderTrigger trigger)", generatedSource); + Assert.Contains("public void Fire(global::PatternKit.Examples.OrderTrigger trigger)", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void BasicStateMachine_Struct_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum LightState { Off, On } + public enum LightTrigger { Toggle } + + [StateMachine(typeof(LightState), typeof(LightTrigger))] + public partial struct LightSwitch + { + [StateTransition(From = LightState.Off, Trigger = LightTrigger.Toggle, To = LightState.On)] + private void TurnOn() { } + + [StateTransition(From = LightState.On, Trigger = LightTrigger.Toggle, To = LightState.Off)] + private void TurnOff() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(BasicStateMachine_Struct_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify struct keyword is used + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("partial struct LightSwitch", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void BasicStateMachine_RecordClass_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum DoorState { Closed, Open } + public enum DoorTrigger { OpenDoor, CloseDoor } + + [StateMachine(typeof(DoorState), typeof(DoorTrigger))] + public partial record class Door + { + [StateTransition(From = DoorState.Closed, Trigger = DoorTrigger.OpenDoor, To = DoorState.Open)] + private void OnOpen() { } + + [StateTransition(From = DoorState.Open, Trigger = DoorTrigger.CloseDoor, To = DoorState.Closed)] + private void OnClose() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(BasicStateMachine_RecordClass_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify record class keyword is used + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("partial record class Door", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void BasicStateMachine_RecordStruct_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum WindowState { Closed, Open } + public enum WindowTrigger { OpenWindow, CloseWindow } + + [StateMachine(typeof(WindowState), typeof(WindowTrigger))] + public partial record struct Window + { + [StateTransition(From = WindowState.Closed, Trigger = WindowTrigger.OpenWindow, To = WindowState.Open)] + private void OnOpen() { } + + [StateTransition(From = WindowState.Open, Trigger = WindowTrigger.CloseWindow, To = WindowState.Closed)] + private void OnClose() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(BasicStateMachine_RecordStruct_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify record struct keyword is used + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("partial record struct Window", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void AsyncStateMachine_WithValueTask_GeneratesCorrectly() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid } + public enum OrderTrigger { Submit, Pay } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private async ValueTask OnSubmitAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + } + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private async ValueTask OnPayAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(AsyncStateMachine_WithValueTask_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify async methods are generated + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("public async global::System.Threading.Tasks.ValueTask FireAsync(global::PatternKit.Examples.OrderTrigger trigger, global::System.Threading.CancellationToken cancellationToken = default)", generatedSource); + Assert.Contains("await OnSubmitAsync(cancellationToken)", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void StateMachineWithGuards_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid } + public enum OrderTrigger { Submit, Pay } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateGuard(From = OrderState.Draft, Trigger = OrderTrigger.Submit)] + private bool CanSubmit() => true; + + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + + [StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] + private bool CanPay() => true; + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private void OnPay() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StateMachineWithGuards_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify guards are called + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("CanSubmit()", generatedSource); + Assert.Contains("CanPay()", generatedSource); + Assert.Contains("if (!CanSubmit())", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void StateMachineWithAsyncGuards_GeneratesCorrectly() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid } + public enum OrderTrigger { Submit, Pay } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateGuard(From = OrderState.Draft, Trigger = OrderTrigger.Submit)] + private async ValueTask CanSubmitAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + return true; + } + + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StateMachineWithAsyncGuards_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify async guards are called + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("await CanSubmitAsync(cancellationToken)", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void StateMachineWithEntryHooks_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid } + public enum OrderTrigger { Submit, Pay } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + + [StateEntry(OrderState.Submitted)] + private void OnEnterSubmitted() { } + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private void OnPay() { } + + [StateEntry(OrderState.Paid)] + private void OnEnterPaid() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StateMachineWithEntryHooks_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify entry hooks are called after state update + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("OnEnterSubmitted()", generatedSource); + Assert.Contains("OnEnterPaid()", generatedSource); + + // Verify State is updated before entry hooks + var submitIndex = generatedSource.IndexOf("State = global::PatternKit.Examples.OrderState.Submitted"); + var entrySubmittedIndex = generatedSource.IndexOf("OnEnterSubmitted()"); + Assert.True(submitIndex < entrySubmittedIndex, "State should be updated before entry hook is called"); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void StateMachineWithExitHooks_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid } + public enum OrderTrigger { Submit, Pay } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateExit(OrderState.Draft)] + private void OnExitDraft() { } + + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + + [StateExit(OrderState.Submitted)] + private void OnExitSubmitted() { } + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private void OnPay() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StateMachineWithExitHooks_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify exit hooks are called + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("OnExitDraft()", generatedSource); + Assert.Contains("OnExitSubmitted()", generatedSource); + + // Verify exit hooks are called before transition action + var exitIndex = generatedSource.IndexOf("OnExitDraft()"); + var transitionIndex = generatedSource.IndexOf("OnSubmit()"); + Assert.True(exitIndex < transitionIndex, "Exit hook should be called before transition action"); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void StateMachineWithAsyncEntryExitHooks_GeneratesCorrectly() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid } + public enum OrderTrigger { Submit, Pay } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateExit(OrderState.Draft)] + private async ValueTask OnExitDraftAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + } + + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + + [StateEntry(OrderState.Submitted)] + private async ValueTask OnEnterSubmittedAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StateMachineWithAsyncEntryExitHooks_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify async entry/exit hooks are awaited + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("await OnExitDraftAsync(cancellationToken)", generatedSource); + Assert.Contains("await OnEnterSubmittedAsync(cancellationToken)", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void StateMachineWithInvalidTriggerPolicy_Ignore_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger), InvalidTrigger = StateMachineInvalidTriggerPolicy.Ignore)] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StateMachineWithInvalidTriggerPolicy_Ignore_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify no exception is thrown for invalid triggers + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.DoesNotContain("throw new global::System.InvalidOperationException", generatedSource); + Assert.Contains("return;", generatedSource); // Should just return instead + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void StateMachineWithGuardFailurePolicy_Ignore_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger), GuardFailure = StateMachineGuardFailurePolicy.Ignore)] + public partial class Machine + { + [StateGuard(From = State.A, Trigger = Trigger.T1)] + private bool CanTransition() => false; + + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StateMachineWithGuardFailurePolicy_Ignore_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify no exception is thrown for guard failures + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + var guardFailureIndex = generatedSource.IndexOf("if (!CanTransition())"); + var throwIndex = generatedSource.IndexOf("throw new global::System.InvalidOperationException($\"Guard failed", guardFailureIndex); + Assert.True(throwIndex == -1, "Should not throw exception on guard failure with Ignore policy"); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void NonPartialType_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NonPartialType_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have diagnostic PKST001 + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST001"); + } + + [Fact] + public void NonEnumStateType_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public class State { } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NonEnumStateType_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have diagnostic PKST002 + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST002"); + } + + [Fact] + public void NonEnumTriggerType_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public class Trigger { } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NonEnumTriggerType_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have diagnostic PKST003 + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST003"); + } + + [Fact] + public void DuplicateTransition_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition1() { } + + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition2() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(DuplicateTransition_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have diagnostic PKST004 + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST004"); + } + + [Fact] + public void InvalidTransitionSignature_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private int OnTransition() => 42; // Invalid return type + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InvalidTransitionSignature_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have diagnostic PKST005 + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST005"); + } + + [Fact] + public void InvalidGuardSignature_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateGuard(From = State.A, Trigger = Trigger.T1)] + private void CanTransition() { } // Invalid return type (should be bool) + + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InvalidGuardSignature_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have diagnostic PKST006 + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST006"); + } + + [Fact] + public void InvalidEntryHookSignature_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + + [StateEntry(State.B)] + private int OnEnterB() => 42; // Invalid return type + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InvalidEntryHookSignature_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have diagnostic PKST007 + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST007"); + } + + [Fact] + public void CompleteOrderFlowExample_GeneratesCorrectly() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum OrderState { Draft, Submitted, Paid, Shipped, Cancelled } + public enum OrderTrigger { Submit, Pay, Ship, Cancel } + + [StateMachine(typeof(OrderState), typeof(OrderTrigger))] + public partial class OrderFlow + { + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Submit, To = OrderState.Submitted)] + private void OnSubmit() { } + + [StateGuard(From = OrderState.Submitted, Trigger = OrderTrigger.Pay)] + private bool CanPay() => true; + + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Pay, To = OrderState.Paid)] + private async ValueTask OnPayAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + } + + [StateExit(OrderState.Paid)] + private void OnExitPaid() { } + + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Ship, To = OrderState.Shipped)] + private void OnShip() { } + + [StateEntry(OrderState.Shipped)] + private void OnEnterShipped() { } + + [StateTransition(From = OrderState.Draft, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + [StateTransition(From = OrderState.Submitted, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + [StateTransition(From = OrderState.Paid, Trigger = OrderTrigger.Cancel, To = OrderState.Cancelled)] + private void OnCancel() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(CompleteOrderFlowExample_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify all expected elements are generated + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("State { get; private set; }", generatedSource); + Assert.Contains("CanFire", generatedSource); + Assert.Contains("Fire(", generatedSource); + Assert.Contains("FireAsync(", generatedSource); + Assert.Contains("CanPay()", generatedSource); + Assert.Contains("OnExitPaid()", generatedSource); + Assert.Contains("OnEnterShipped()", generatedSource); + Assert.Contains("await OnPayAsync(cancellationToken)", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void CustomMethodNames_GeneratesCorrectly() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger), + FireMethodName = "Transition", + FireAsyncMethodName = "TransitionAsync", + CanFireMethodName = "CanTransition")] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(CustomMethodNames_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify custom method names are used + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("public bool CanTransition", generatedSource); + Assert.Contains("public void Transition", generatedSource); + Assert.DoesNotContain("public void Fire(", generatedSource); + Assert.DoesNotContain("public bool CanFire", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateAsyncFalse_WithAsyncMethods_ReportsDiagnostic() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger), GenerateAsync = false)] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private async ValueTask OnTransitionAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAsyncFalse_WithAsyncMethods_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKST008 diagnostic + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + + // Debug: print all diagnostics + if (diagnostics.Length == 0) + { + // Check if code was even generated + var hasGeneratedCode = result.Results.Any(r => r.GeneratedSources.Length > 0); + Assert.True(hasGeneratedCode, "No code was generated"); + + // Check compilation diagnostics + var compDiags = updated.GetDiagnostics().Where(d => d.Id.StartsWith("PKST")).ToArray(); + Assert.True(compDiags.Length > 0, $"No PKST diagnostics found. Generated code: {result.Results[0].GeneratedSources.Length} files"); + } + + Assert.Contains(diagnostics, d => d.Id == "PKST008"); + + // Verify FireAsync is NOT generated + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.DoesNotContain("FireAsync", generatedSource); + Assert.Contains("public void Fire", generatedSource); + + // And the updated compilation actually compiles (sync Fire should block on async method) + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GuardWithCancellationToken_GeneratesCorrectly() + { + var source = """ + using System.Threading; + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateGuard(From = State.A, Trigger = Trigger.T1)] + private bool CanTransition(CancellationToken ct) => true; + + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GuardWithCancellationToken_GeneratesCorrectly)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify guard is called with CancellationToken.None in CanFire + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("CanTransition(global::System.Threading.CancellationToken.None)", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void AsyncGuardInCanFire_EvaluatesSynchronously() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateGuard(From = State.A, Trigger = Trigger.T1)] + private async ValueTask CanTransitionAsync(CancellationToken ct) + { + await Task.Delay(10, ct); + return true; + } + + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(AsyncGuardInCanFire_EvaluatesSynchronously)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify async guard is evaluated synchronously with GetAwaiter().GetResult() + var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("CanTransitionAsync(global::System.Threading.CancellationToken.None).GetAwaiter().GetResult()", generatedSource); + + // And the updated compilation actually compiles + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenericType_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenericType_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKST009 diagnostic + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST009"); + } + + [Fact] + public void NestedType_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.State; + + namespace PatternKit.Examples; + + public enum State { A, B } + public enum Trigger { T1 } + + public class Outer + { + [StateMachine(typeof(State), typeof(Trigger))] + public partial class Machine + { + [StateTransition(From = State.A, Trigger = Trigger.T1, To = State.B)] + private void OnTransition() { } + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NestedType_ReportsDiagnostic)); + var gen = new StateMachineGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKST010 diagnostic + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKST010"); + } +}