diff --git a/src/PatternKit.Generators.Abstractions/Composer/ComposeIgnoreAttribute.cs b/src/PatternKit.Generators.Abstractions/Composer/ComposeIgnoreAttribute.cs
new file mode 100644
index 0000000..37cc783
--- /dev/null
+++ b/src/PatternKit.Generators.Abstractions/Composer/ComposeIgnoreAttribute.cs
@@ -0,0 +1,10 @@
+namespace PatternKit.Generators.Composer;
+
+///
+/// Marks a method to be excluded from pipeline composition.
+/// Use this to explicitly exclude methods that might otherwise be considered for composition.
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
+public sealed class ComposeIgnoreAttribute : Attribute
+{
+}
diff --git a/src/PatternKit.Generators.Abstractions/Composer/ComposeStepAttribute.cs b/src/PatternKit.Generators.Abstractions/Composer/ComposeStepAttribute.cs
new file mode 100644
index 0000000..50a647f
--- /dev/null
+++ b/src/PatternKit.Generators.Abstractions/Composer/ComposeStepAttribute.cs
@@ -0,0 +1,32 @@
+namespace PatternKit.Generators.Composer;
+
+///
+/// Marks a method as a pipeline step that will be composed into the pipeline.
+/// Steps are ordered by the Order property and wrap each other according to the ComposerWrapOrder.
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
+public sealed class ComposeStepAttribute : Attribute
+{
+ ///
+ /// Gets or sets the order of this step in the pipeline.
+ /// With OuterFirst (default): Lower Order values wrap higher Order values.
+ /// - Order=0 executes first, wrapping all other steps.
+ /// With InnerFirst: Higher Order values wrap lower Order values.
+ /// - Order=0 executes last, closest to the terminal.
+ ///
+ public int Order { get; set; }
+
+ ///
+ /// Gets or sets an optional name for this step (for diagnostics and debugging).
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The order of this step in the pipeline.
+ public ComposeStepAttribute(int order)
+ {
+ Order = order;
+ }
+}
diff --git a/src/PatternKit.Generators.Abstractions/Composer/ComposeTerminalAttribute.cs b/src/PatternKit.Generators.Abstractions/Composer/ComposeTerminalAttribute.cs
new file mode 100644
index 0000000..14f76e3
--- /dev/null
+++ b/src/PatternKit.Generators.Abstractions/Composer/ComposeTerminalAttribute.cs
@@ -0,0 +1,11 @@
+namespace PatternKit.Generators.Composer;
+
+///
+/// Marks a method as the terminal step of the pipeline.
+/// The terminal is the final step that produces the output without calling a 'next' delegate.
+/// A pipeline must have exactly one terminal.
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
+public sealed class ComposeTerminalAttribute : Attribute
+{
+}
diff --git a/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs b/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs
new file mode 100644
index 0000000..484586f
--- /dev/null
+++ b/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs
@@ -0,0 +1,59 @@
+namespace PatternKit.Generators.Composer;
+
+///
+/// Marks a partial type as a composer pipeline host that will generate deterministic
+/// composition of ordered components into a single executable pipeline.
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
+public sealed class ComposerAttribute : Attribute
+{
+ ///
+ /// Gets or sets the name of the generated synchronous invoke method.
+ /// Default is "Invoke".
+ ///
+ public string InvokeMethodName { get; set; } = "Invoke";
+
+ ///
+ /// Gets or sets the name of the generated asynchronous invoke method.
+ /// Default is "InvokeAsync".
+ ///
+ public string InvokeAsyncMethodName { get; set; } = "InvokeAsync";
+
+ ///
+ /// Gets or sets whether to generate async methods.
+ /// When null (default), async generation is inferred from the presence of async steps or terminal.
+ /// Note: Nullable bool in attributes is non-standard but supported by C#.
+ /// Set to true/false explicitly to control async generation, or leave unset for inference.
+ ///
+ public bool? GenerateAsync { get; set; }
+
+ ///
+ /// Gets or sets whether to force async generation even if all steps are synchronous.
+ /// Default is false.
+ ///
+ public bool ForceAsync { get; set; }
+
+ ///
+ /// Gets or sets the wrapping order for pipeline steps.
+ /// Default is OuterFirst (step with Order=0 is outermost).
+ ///
+ public ComposerWrapOrder WrapOrder { get; set; } = ComposerWrapOrder.OuterFirst;
+}
+
+///
+/// Defines the order in which pipeline steps wrap each other.
+///
+public enum ComposerWrapOrder
+{
+ ///
+ /// Steps with lower Order values wrap steps with higher Order values.
+ /// Order=0 is the outermost wrapper (executes first).
+ ///
+ OuterFirst = 0,
+
+ ///
+ /// Steps with higher Order values wrap steps with lower Order values.
+ /// Order=0 is the innermost wrapper (executes last, closest to terminal).
+ ///
+ InnerFirst = 1
+}
diff --git a/src/PatternKit.Generators/ComposerGenerator.cs b/src/PatternKit.Generators/ComposerGenerator.cs
new file mode 100644
index 0000000..ff62060
--- /dev/null
+++ b/src/PatternKit.Generators/ComposerGenerator.cs
@@ -0,0 +1,751 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using PatternKit.Generators.Composer;
+using System.Text;
+
+namespace PatternKit.Generators;
+
+///
+/// Source generator for the Composer pattern.
+/// Generates deterministic composition of ordered pipeline components into executable pipelines.
+///
+[Generator]
+public sealed class ComposerGenerator : IIncrementalGenerator
+{
+ // Diagnostic IDs
+ private const string DiagIdNotPartial = "PKCOM001";
+ private const string DiagIdNoSteps = "PKCOM002";
+ private const string DiagIdDuplicateOrder = "PKCOM003";
+ private const string DiagIdNoTerminal = "PKCOM004";
+ private const string DiagIdMultipleTerminals = "PKCOM005";
+ private const string DiagIdInvalidStepSignature = "PKCOM006";
+ private const string DiagIdInvalidTerminalSignature = "PKCOM007";
+ private const string DiagIdAsyncNotEnabled = "PKCOM008";
+ private const string DiagIdMissingCancellationToken = "PKCOM009";
+
+ private static readonly DiagnosticDescriptor NotPartialDescriptor = new(
+ id: DiagIdNotPartial,
+ title: "Composer type must be partial",
+ messageFormat: "Type '{0}' marked with [Composer] must be declared as partial",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor NoStepsDescriptor = new(
+ id: DiagIdNoSteps,
+ title: "No compose steps found",
+ messageFormat: "Type '{0}' marked with [Composer] has no methods marked with [ComposeStep]",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor DuplicateOrderDescriptor = new(
+ id: DiagIdDuplicateOrder,
+ title: "Duplicate step order",
+ messageFormat: "Multiple steps have Order={0}. Each step must have a unique Order value",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor NoTerminalDescriptor = new(
+ id: DiagIdNoTerminal,
+ title: "Missing terminal step",
+ messageFormat: "Type '{0}' marked with [Composer] must have exactly one method marked with [ComposeTerminal]",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor MultipleTerminalsDescriptor = new(
+ id: DiagIdMultipleTerminals,
+ title: "Multiple terminal steps",
+ messageFormat: "Type '{0}' has multiple methods marked with [ComposeTerminal]. Only one terminal is allowed",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor InvalidStepSignatureDescriptor = new(
+ id: DiagIdInvalidStepSignature,
+ title: "Invalid step method signature",
+ messageFormat: "Method '{0}' has an invalid signature for a pipeline step. Expected: TOut Step(in TIn, Func next) or ValueTask StepAsync(TIn, Func> next, CancellationToken)",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor InvalidTerminalSignatureDescriptor = new(
+ id: DiagIdInvalidTerminalSignature,
+ title: "Invalid terminal method signature",
+ messageFormat: "Method '{0}' has an invalid signature for a terminal. Expected: TOut Terminal(in TIn) or ValueTask TerminalAsync(TIn, CancellationToken)",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor AsyncNotEnabledDescriptor = new(
+ id: DiagIdAsyncNotEnabled,
+ title: "Async step detected but async generation disabled",
+ messageFormat: "Method '{0}' is async but async generation is disabled. Set GenerateAsync=true or ForceAsync=true on [Composer]",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor MissingCancellationTokenDescriptor = new(
+ id: DiagIdMissingCancellationToken,
+ title: "CancellationToken parameter required",
+ messageFormat: "Method '{0}' is async but missing CancellationToken parameter. Async methods should have a CancellationToken parameter",
+ category: "PatternKit.Generators.Composer",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Find all types marked with [Composer]
+ var composerTypes = context.SyntaxProvider.ForAttributeWithMetadataName(
+ fullyQualifiedMetadataName: "PatternKit.Generators.Composer.ComposerAttribute",
+ predicate: static (node, _) => node is ClassDeclarationSyntax or StructDeclarationSyntax or RecordDeclarationSyntax,
+ transform: static (ctx, _) => ctx
+ );
+
+ // Generate for each composer type
+ context.RegisterSourceOutput(composerTypes, (spc, composerContext) =>
+ {
+ if (composerContext.TargetSymbol is not INamedTypeSymbol typeSymbol)
+ return;
+
+ var attr = composerContext.Attributes.FirstOrDefault(a =>
+ a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Composer.ComposerAttribute");
+ if (attr is null)
+ return;
+
+ GenerateComposer(spc, typeSymbol, attr, composerContext.TargetNode);
+ });
+ }
+
+ private void GenerateComposer(
+ SourceProductionContext context,
+ INamedTypeSymbol typeSymbol,
+ AttributeData attribute,
+ SyntaxNode node)
+ {
+ // Check if type is partial
+ if (!IsPartial(node))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ NotPartialDescriptor,
+ node.GetLocation(),
+ typeSymbol.Name));
+ return;
+ }
+
+ // Parse attribute configuration
+ var config = ParseComposerConfig(attribute);
+
+ // Find all steps and terminal
+ var steps = FindSteps(typeSymbol, context);
+ var terminals = FindTerminals(typeSymbol, context);
+
+ // Validate we have steps
+ if (steps.Count == 0)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ NoStepsDescriptor,
+ node.GetLocation(),
+ typeSymbol.Name));
+ return;
+ }
+
+ // Validate exactly one terminal
+ if (terminals.Count == 0)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ NoTerminalDescriptor,
+ node.GetLocation(),
+ typeSymbol.Name));
+ return;
+ }
+
+ if (terminals.Count > 1)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ MultipleTerminalsDescriptor,
+ node.GetLocation(),
+ typeSymbol.Name));
+ return;
+ }
+
+ var terminal = terminals[0];
+
+ // Check for duplicate orders
+ var orderGroups = steps.GroupBy(s => s.Order).Where(g => g.Count() > 1).ToList();
+ if (orderGroups.Any())
+ {
+ foreach (var group in orderGroups)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DuplicateOrderDescriptor,
+ group.First().Method.Locations.FirstOrDefault() ?? node.GetLocation(),
+ group.Key));
+ }
+ return;
+ }
+
+ // Validate signatures and determine async mode
+ bool hasAsyncSteps = steps.Any(s => s.IsAsync) || terminal.IsAsync;
+ bool shouldGenerateAsync = config.ForceAsync || (config.GenerateAsync ?? hasAsyncSteps);
+
+ if (hasAsyncSteps && config.GenerateAsync == false)
+ {
+ var asyncStep = steps.FirstOrDefault(s => s.IsAsync);
+ var methodToReport = asyncStep?.Method ?? terminal.Method;
+ context.ReportDiagnostic(Diagnostic.Create(
+ AsyncNotEnabledDescriptor,
+ methodToReport.Locations.FirstOrDefault() ?? node.GetLocation(),
+ methodToReport.Name));
+ return;
+ }
+
+ // Validate step signatures
+ foreach (var step in steps)
+ {
+ if (!ValidateStepSignature(step, context))
+ return;
+ }
+
+ // Validate terminal signature
+ if (!ValidateTerminalSignature(terminal, context))
+ return;
+
+ // Determine pipeline input and output types
+ var inputType = DetermineInputType(steps, terminal);
+ var outputType = DetermineOutputType(steps, terminal);
+
+ if (inputType == null || outputType == null)
+ {
+ // Signature validation should have caught this, but be safe
+ return;
+ }
+
+ // Sort steps by order
+ var orderedSteps = config.WrapOrder == ComposerWrapOrder.OuterFirst
+ ? steps.OrderBy(s => s.Order).ToList()
+ : steps.OrderByDescending(s => s.Order).ToList();
+
+ // Generate the source
+ var source = GenerateSource(typeSymbol, config, orderedSteps, terminal, inputType, outputType, shouldGenerateAsync);
+
+ var fileName = $"{typeSymbol.Name}.Composer.g.cs";
+ context.AddSource(fileName, source);
+ }
+
+ private ComposerConfig ParseComposerConfig(AttributeData attribute)
+ {
+ var config = new ComposerConfig();
+
+ foreach (var namedArg in attribute.NamedArguments)
+ {
+ switch (namedArg.Key)
+ {
+ case "InvokeMethodName":
+ if (namedArg.Value.Value is string invokeName && !string.IsNullOrWhiteSpace(invokeName))
+ config.InvokeMethodName = invokeName;
+ break;
+ case "InvokeAsyncMethodName":
+ if (namedArg.Value.Value is string invokeAsyncName && !string.IsNullOrWhiteSpace(invokeAsyncName))
+ config.InvokeAsyncMethodName = invokeAsyncName;
+ break;
+ case "GenerateAsync":
+ if (namedArg.Value.Value is bool genAsync)
+ config.GenerateAsync = genAsync;
+ break;
+ case "ForceAsync":
+ if (namedArg.Value.Value is bool forceAsync)
+ config.ForceAsync = forceAsync;
+ break;
+ case "WrapOrder":
+ if (namedArg.Value.Value is int wrapOrder && wrapOrder >= 0 && wrapOrder <= 1)
+ config.WrapOrder = (ComposerWrapOrder)wrapOrder;
+ break;
+ }
+ }
+
+ return config;
+ }
+
+ private List FindSteps(INamedTypeSymbol typeSymbol, SourceProductionContext context)
+ {
+ var steps = new List();
+
+ foreach (var method in typeSymbol.GetMembers().OfType())
+ {
+ var stepAttr = method.GetAttributes()
+ .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Composer.ComposeStepAttribute");
+
+ if (stepAttr == null)
+ continue;
+
+ // Check for [ComposeIgnore]
+ var ignoreAttr = method.GetAttributes()
+ .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Composer.ComposeIgnoreAttribute");
+ if (ignoreAttr != null)
+ continue;
+
+ int order = 0;
+ string? name = null;
+
+ if (stepAttr.ConstructorArguments.Length > 0 &&
+ stepAttr.ConstructorArguments[0].Value is int orderValue)
+ {
+ order = orderValue;
+ }
+
+ foreach (var namedArg in stepAttr.NamedArguments)
+ {
+ if (namedArg.Key == "Order" && namedArg.Value.Value is int orderVal)
+ order = orderVal;
+ else if (namedArg.Key == "Name" && namedArg.Value.Value is string nameVal)
+ name = nameVal;
+ }
+
+ bool isAsync = IsAsyncMethod(method);
+
+ steps.Add(new StepInfo
+ {
+ Method = method,
+ Order = order,
+ Name = name ?? method.Name,
+ IsAsync = isAsync
+ });
+ }
+
+ return steps;
+ }
+
+ private List FindTerminals(INamedTypeSymbol typeSymbol, SourceProductionContext context)
+ {
+ var terminals = new List();
+
+ foreach (var method in typeSymbol.GetMembers().OfType())
+ {
+ var terminalAttr = method.GetAttributes()
+ .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Composer.ComposeTerminalAttribute");
+
+ if (terminalAttr == null)
+ continue;
+
+ bool isAsync = IsAsyncMethod(method);
+
+ terminals.Add(new TerminalInfo
+ {
+ Method = method,
+ IsAsync = isAsync
+ });
+ }
+
+ return terminals;
+ }
+
+ private bool ValidateStepSignature(StepInfo step, SourceProductionContext context)
+ {
+ var method = step.Method;
+
+ // Basic checks: should have at least 2 parameters (input, next)
+ // Async version: input, next, optionally cancellationToken
+ if (method.Parameters.Length < 2)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ InvalidStepSignatureDescriptor,
+ method.Locations.FirstOrDefault(),
+ method.Name));
+ return false;
+ }
+
+ // Check if second parameter is a Func delegate
+ var nextParam = method.Parameters[1];
+ if (nextParam.Type is not INamedTypeSymbol nextType)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ InvalidStepSignatureDescriptor,
+ method.Locations.FirstOrDefault(),
+ method.Name));
+ return false;
+ }
+
+ // Validate it's actually System.Func by checking namespace and name
+ var fullName = nextType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ if (!fullName.StartsWith("global::System.Func<"))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ InvalidStepSignatureDescriptor,
+ method.Locations.FirstOrDefault(),
+ method.Name));
+ return false;
+ }
+
+ // For async methods, check for CancellationToken
+ if (step.IsAsync && method.Parameters.Length >= 3)
+ {
+ var lastParam = method.Parameters[method.Parameters.Length - 1];
+ if (lastParam.Type.ToDisplayString() != "System.Threading.CancellationToken")
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ MissingCancellationTokenDescriptor,
+ method.Locations.FirstOrDefault(),
+ method.Name));
+ }
+ }
+
+ return true;
+ }
+
+ private bool ValidateTerminalSignature(TerminalInfo terminal, SourceProductionContext context)
+ {
+ var method = terminal.Method;
+
+ // Terminal should have exactly 1 parameter (input) or 2 for async (input, cancellationToken)
+ if (method.Parameters.Length < 1 || method.Parameters.Length > 2)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ InvalidTerminalSignatureDescriptor,
+ method.Locations.FirstOrDefault(),
+ method.Name));
+ return false;
+ }
+
+ // For async terminals, check for CancellationToken
+ if (terminal.IsAsync && method.Parameters.Length == 2)
+ {
+ var lastParam = method.Parameters[1];
+ if (lastParam.Type.ToDisplayString() != "System.Threading.CancellationToken")
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ MissingCancellationTokenDescriptor,
+ method.Locations.FirstOrDefault(),
+ method.Name));
+ }
+ }
+
+ return true;
+ }
+
+ private ITypeSymbol? DetermineInputType(List steps, TerminalInfo terminal)
+ {
+ // Get input type from terminal (first parameter)
+ return terminal.Method.Parameters.FirstOrDefault()?.Type;
+ }
+
+ private ITypeSymbol? DetermineOutputType(List steps, TerminalInfo terminal)
+ {
+ // Get output type from terminal return type
+ var returnType = terminal.Method.ReturnType;
+
+ // If it's ValueTask or Task, unwrap it
+ if (returnType is INamedTypeSymbol namedType &&
+ (namedType.Name == "ValueTask" || namedType.Name == "Task") &&
+ namedType.TypeArguments.Length > 0)
+ {
+ return namedType.TypeArguments[0];
+ }
+
+ return returnType;
+ }
+
+ private string GenerateSource(
+ INamedTypeSymbol typeSymbol,
+ ComposerConfig config,
+ List orderedSteps,
+ TerminalInfo terminal,
+ ITypeSymbol inputType,
+ ITypeSymbol outputType,
+ bool generateAsync)
+ {
+ var sb = new StringBuilder();
+ var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : typeSymbol.ContainingNamespace.ToDisplayString();
+
+ var typeKind = typeSymbol.TypeKind == TypeKind.Struct ? "struct" : "class";
+ var isRecord = typeSymbol.IsRecord;
+ var typeDecl = isRecord ? $"partial record {typeKind}" : $"partial {typeKind}";
+
+ // File header
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Threading;");
+ sb.AppendLine("using System.Threading.Tasks;");
+ sb.AppendLine();
+
+ if (ns != null)
+ {
+ sb.AppendLine($"namespace {ns};");
+ sb.AppendLine();
+ }
+
+ // Type declaration
+ sb.AppendLine($"{typeDecl} {typeSymbol.Name}");
+ sb.AppendLine("{");
+
+ bool isStruct = typeSymbol.TypeKind == TypeKind.Struct;
+
+ // Generate sync Invoke if we have sync steps (always generate unless ForceAsync with async steps)
+ bool hasSyncSteps = !orderedSteps.Any(s => s.IsAsync) && !terminal.IsAsync;
+ bool generatedSyncVersion = false;
+ if (hasSyncSteps)
+ {
+ GenerateSyncInvoke(sb, config, orderedSteps, terminal, inputType, outputType, isStruct);
+ generatedSyncVersion = true;
+ }
+
+ // Generate async InvokeAsync if needed
+ if (generateAsync)
+ {
+ // Add a blank line only if we also generated the sync version
+ if (generatedSyncVersion)
+ sb.AppendLine();
+ GenerateAsyncInvoke(sb, config, orderedSteps, terminal, inputType, outputType, isStruct);
+ }
+
+ sb.AppendLine("}");
+
+ return sb.ToString();
+ }
+
+ private void GenerateSyncInvoke(
+ StringBuilder sb,
+ ComposerConfig config,
+ List orderedSteps,
+ TerminalInfo terminal,
+ ITypeSymbol inputType,
+ ITypeSymbol outputType,
+ bool isStruct)
+ {
+ var inputTypeStr = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var outputTypeStr = outputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ sb.AppendLine($" public {outputTypeStr} {config.InvokeMethodName}(in {inputTypeStr} input)");
+ sb.AppendLine(" {");
+
+ if (isStruct)
+ {
+ // For structs, we need to avoid lambdas that capture 'this'
+ // We'll create a copy of 'this' and use it in local functions
+ sb.AppendLine($" var self = this;");
+
+ // Start with terminal wrapped as a local function using the copy
+ sb.AppendLine($" {outputTypeStr} terminalFunc({inputTypeStr} arg) => self.{terminal.Method.Name}(in arg);");
+ sb.AppendLine($" global::System.Func<{inputTypeStr}, {outputTypeStr}> pipeline = terminalFunc;");
+
+ // Wrap each step around the previous pipeline
+ for (int i = orderedSteps.Count - 1; i >= 0; i--)
+ {
+ var step = orderedSteps[i];
+ var funcName = $"step{i}Func";
+ sb.AppendLine($" {outputTypeStr} {funcName}({inputTypeStr} arg) => self.{step.Method.Name}(in arg, pipeline);");
+ sb.AppendLine($" pipeline = {funcName};");
+ }
+
+ // Invoke the final pipeline
+ sb.AppendLine($" return pipeline(input);");
+ }
+ else
+ {
+ // For classes, use the lambda approach
+ // Build the pipeline from innermost (terminal) to outermost
+ // We'll build a chain of Func delegates
+
+ // Start with terminal wrapped as a Func
+ sb.AppendLine($" global::System.Func<{inputTypeStr}, {outputTypeStr}> pipeline = (arg) => {terminal.Method.Name}(in arg);");
+
+ // Wrap each step around the previous pipeline
+ for (int i = orderedSteps.Count - 1; i >= 0; i--)
+ {
+ var step = orderedSteps[i];
+ sb.AppendLine($" pipeline = (arg) => {step.Method.Name}(in arg, pipeline);");
+ }
+
+ // Invoke the final pipeline
+ sb.AppendLine($" return pipeline(input);");
+ }
+
+ sb.AppendLine(" }");
+ }
+
+ private void GenerateAsyncInvoke(
+ StringBuilder sb,
+ ComposerConfig config,
+ List orderedSteps,
+ TerminalInfo terminal,
+ ITypeSymbol inputType,
+ ITypeSymbol outputType,
+ bool isStruct)
+ {
+ var inputTypeStr = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var outputTypeStr = outputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ sb.AppendLine($" public global::System.Threading.Tasks.ValueTask<{outputTypeStr}> {config.InvokeAsyncMethodName}({inputTypeStr} input, global::System.Threading.CancellationToken cancellationToken = default)");
+ sb.AppendLine(" {");
+
+ if (isStruct)
+ {
+ // For structs, we need to avoid lambdas that capture 'this'
+ // We'll create a copy of 'this' and use it in local functions, similar to sync approach
+ sb.AppendLine($" var self = this;");
+
+ // Start with terminal wrapped as a local function using the copy
+ if (terminal.IsAsync)
+ {
+ if (terminal.Method.Parameters.Length > 1 &&
+ terminal.Method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken")
+ {
+ sb.AppendLine($" global::System.Threading.Tasks.ValueTask<{outputTypeStr}> terminalFunc({inputTypeStr} arg) => self.{terminal.Method.Name}(arg, cancellationToken);");
+ }
+ else
+ {
+ sb.AppendLine($" global::System.Threading.Tasks.ValueTask<{outputTypeStr}> terminalFunc({inputTypeStr} arg) => self.{terminal.Method.Name}(arg);");
+ }
+ }
+ else
+ {
+ sb.AppendLine($" global::System.Threading.Tasks.ValueTask<{outputTypeStr}> terminalFunc({inputTypeStr} arg) => new global::System.Threading.Tasks.ValueTask<{outputTypeStr}>(self.{terminal.Method.Name}(in arg));");
+ }
+
+ sb.AppendLine($" global::System.Func<{inputTypeStr}, global::System.Threading.Tasks.ValueTask<{outputTypeStr}>> pipeline = terminalFunc;");
+
+ // Wrap each step around the previous pipeline
+ for (int i = orderedSteps.Count - 1; i >= 0; i--)
+ {
+ var step = orderedSteps[i];
+ var funcName = $"step{i}Func";
+
+ if (step.IsAsync)
+ {
+ // Check if step has cancellationToken parameter
+ if (step.Method.Parameters.Length > 2 &&
+ step.Method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken")
+ {
+ sb.AppendLine($" global::System.Threading.Tasks.ValueTask<{outputTypeStr}> {funcName}({inputTypeStr} arg) => self.{step.Method.Name}(arg, pipeline, cancellationToken);");
+ }
+ else
+ {
+ sb.AppendLine($" global::System.Threading.Tasks.ValueTask<{outputTypeStr}> {funcName}({inputTypeStr} arg) => self.{step.Method.Name}(arg, pipeline);");
+ }
+ }
+ else
+ {
+ // Wrap sync step to work with async pipeline
+ // WARNING: GetAwaiter().GetResult() can deadlock in certain synchronization contexts (UI thread, ASP.NET pre-Core)
+ // Avoid using mixed sync/async pipelines in contexts with custom SynchronizationContext
+ sb.AppendLine($" global::System.Threading.Tasks.ValueTask<{outputTypeStr}> {funcName}({inputTypeStr} arg) => new global::System.Threading.Tasks.ValueTask<{outputTypeStr}>(self.{step.Method.Name}(in arg, inp => pipeline(inp).GetAwaiter().GetResult()));");
+ }
+
+ sb.AppendLine($" pipeline = {funcName};");
+ }
+
+ // Invoke the final pipeline
+ sb.AppendLine($" return pipeline(input);");
+ }
+ else
+ {
+ // Build the async pipeline using Func delegates
+ // If terminal is async, use it directly; otherwise wrap in ValueTask.FromResult
+ if (terminal.IsAsync)
+ {
+ if (terminal.Method.Parameters.Length > 1 &&
+ terminal.Method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken")
+ {
+ sb.AppendLine($" global::System.Func<{inputTypeStr}, global::System.Threading.Tasks.ValueTask<{outputTypeStr}>> pipeline = (arg) => {terminal.Method.Name}(arg, cancellationToken);");
+ }
+ else
+ {
+ sb.AppendLine($" global::System.Func<{inputTypeStr}, global::System.Threading.Tasks.ValueTask<{outputTypeStr}>> pipeline = (arg) => {terminal.Method.Name}(arg);");
+ }
+ }
+ else
+ {
+ sb.AppendLine($" global::System.Func<{inputTypeStr}, global::System.Threading.Tasks.ValueTask<{outputTypeStr}>> pipeline = (arg) => new global::System.Threading.Tasks.ValueTask<{outputTypeStr}>({terminal.Method.Name}(in arg));");
+ }
+
+ // Wrap each step around the previous pipeline
+ for (int i = orderedSteps.Count - 1; i >= 0; i--)
+ {
+ var step = orderedSteps[i];
+
+ if (step.IsAsync)
+ {
+ // Check if step has cancellationToken parameter
+ if (step.Method.Parameters.Length > 2 &&
+ step.Method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken")
+ {
+ sb.AppendLine($" pipeline = (arg) => {step.Method.Name}(arg, pipeline, cancellationToken);");
+ }
+ else
+ {
+ sb.AppendLine($" pipeline = (arg) => {step.Method.Name}(arg, pipeline);");
+ }
+ }
+ else
+ {
+ // Wrap sync step to work with async pipeline
+ // WARNING: GetAwaiter().GetResult() can deadlock in certain synchronization contexts (UI thread, ASP.NET pre-Core)
+ // Avoid using mixed sync/async pipelines in contexts with custom SynchronizationContext
+ sb.AppendLine($" {{");
+ sb.AppendLine($" var prevPipeline = pipeline;");
+ sb.AppendLine($" pipeline = (arg) => new global::System.Threading.Tasks.ValueTask<{outputTypeStr}>({step.Method.Name}(in arg, inp => prevPipeline(inp).GetAwaiter().GetResult()));");
+ sb.AppendLine($" }}");
+ }
+ }
+
+ // Invoke the final pipeline
+ sb.AppendLine($" return pipeline(input);");
+ }
+
+ sb.AppendLine(" }");
+ }
+
+ private bool IsAsyncMethod(IMethodSymbol method)
+ {
+ var returnType = method.ReturnType;
+ if (returnType is not INamedTypeSymbol namedType)
+ return false;
+
+ var fullName = namedType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ return fullName == "global::System.Threading.Tasks.Task" ||
+ fullName.StartsWith("global::System.Threading.Tasks.Task<") ||
+ fullName == "global::System.Threading.Tasks.ValueTask" ||
+ fullName.StartsWith("global::System.Threading.Tasks.ValueTask<");
+ }
+
+ private bool IsPartial(SyntaxNode node)
+ {
+ return node switch
+ {
+ ClassDeclarationSyntax cls => cls.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)),
+ StructDeclarationSyntax str => str.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)),
+ RecordDeclarationSyntax rec => rec.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)),
+ _ => false
+ };
+ }
+
+ private class ComposerConfig
+ {
+ public string InvokeMethodName { get; set; } = "Invoke";
+ public string InvokeAsyncMethodName { get; set; } = "InvokeAsync";
+ public bool? GenerateAsync { get; set; }
+ public bool ForceAsync { get; set; }
+ public ComposerWrapOrder WrapOrder { get; set; } = ComposerWrapOrder.OuterFirst;
+ }
+
+ private class StepInfo
+ {
+ public IMethodSymbol Method { get; set; } = null!;
+ public int Order { get; set; }
+ public string Name { get; set; } = null!;
+ public bool IsAsync { get; set; }
+ }
+
+ private class TerminalInfo
+ {
+ public IMethodSymbol Method { get; set; } = null!;
+ public bool IsAsync { get; set; }
+ }
+}
diff --git a/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs b/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs
new file mode 100644
index 0000000..23f0c62
--- /dev/null
+++ b/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs
@@ -0,0 +1,825 @@
+using Microsoft.CodeAnalysis;
+
+namespace PatternKit.Generators.Tests;
+
+public class ComposerGeneratorTests
+{
+ [Fact]
+ public void BasicSyncPipeline_GeneratesCorrectly()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Auth(in Request req, System.Func next)
+ {
+ if (req.Path == "/forbidden")
+ return new Response(403);
+ return next(req);
+ }
+
+ [ComposeStep(1)]
+ private Response Logging(in Request req, System.Func next)
+ {
+ System.Console.WriteLine($"Request: {req.Path}");
+ return next(req);
+ }
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(BasicSyncPipeline_GeneratesCorrectly));
+ var gen = new ComposerGenerator();
+ _ = 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("RequestPipeline.Composer.g.cs", names);
+
+ // Verify the generated source contains Invoke method
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("public global::PatternKit.Examples.Response Invoke(in global::PatternKit.Examples.Request input)", 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 AsyncPipeline_WithValueTask_GeneratesCorrectly()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class AsyncRequestPipeline
+ {
+ [ComposeStep(0)]
+ private async ValueTask AuthAsync(Request req, Func> next, CancellationToken ct)
+ {
+ await Task.Delay(10, ct);
+ if (req.Path == "/forbidden")
+ return new Response(403);
+ return await next(req);
+ }
+
+ [ComposeTerminal]
+ private ValueTask TerminalAsync(Request req, CancellationToken ct) =>
+ new ValueTask(new Response(200));
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(AsyncPipeline_WithValueTask_GeneratesCorrectly));
+ var gen = new ComposerGenerator();
+ _ = 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("AsyncRequestPipeline.Composer.g.cs", names);
+
+ // Verify the generated source contains InvokeAsync method
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("InvokeAsync", generatedSource);
+ Assert.Contains("ValueTask", generatedSource);
+ Assert.Contains("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 NotPartial_ProducesDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public class RequestPipeline // Missing 'partial'
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NotPartial_ProducesDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ // Should have PKCOM001 diagnostic
+ var diagnostics = result.Results[0].Diagnostics;
+ Assert.NotEmpty(diagnostics);
+ Assert.Contains(diagnostics, d => d.Id == "PKCOM001");
+ }
+
+ [Fact]
+ public void NoSteps_ProducesDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NoSteps_ProducesDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ // Should have PKCOM002 diagnostic
+ var diagnostics = result.Results[0].Diagnostics;
+ Assert.NotEmpty(diagnostics);
+ Assert.Contains(diagnostics, d => d.Id == "PKCOM002");
+ }
+
+ [Fact]
+ public void NoTerminal_ProducesDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NoTerminal_ProducesDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ // Should have PKCOM004 diagnostic
+ var diagnostics = result.Results[0].Diagnostics;
+ Assert.NotEmpty(diagnostics);
+ Assert.Contains(diagnostics, d => d.Id == "PKCOM004");
+ }
+
+ [Fact]
+ public void MultipleTerminals_ProducesDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal1(in Request req) => new(200);
+
+ [ComposeTerminal]
+ private Response Terminal2(in Request req) => new(404);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(MultipleTerminals_ProducesDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ // Should have PKCOM005 diagnostic
+ var diagnostics = result.Results[0].Diagnostics;
+ Assert.NotEmpty(diagnostics);
+ Assert.Contains(diagnostics, d => d.Id == "PKCOM005");
+ }
+
+ [Fact]
+ public void DuplicateOrder_ProducesDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step1(in Request req, System.Func next) => next(req);
+
+ [ComposeStep(0)]
+ private Response Step2(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(DuplicateOrder_ProducesDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ // Should have PKCOM003 diagnostic
+ var diagnostics = result.Results[0].Diagnostics;
+ Assert.NotEmpty(diagnostics);
+ Assert.Contains(diagnostics, d => d.Id == "PKCOM003");
+ }
+
+ [Fact]
+ public void StructType_GeneratesCorrectly()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial struct RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StructType_GeneratesCorrectly));
+ var gen = new ComposerGenerator();
+ _ = 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("RequestPipeline.Composer.g.cs", names);
+
+ // Verify the generated source contains 'partial struct'
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("partial struct RequestPipeline", 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 RecordClass_GeneratesCorrectly()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial record class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordClass_GeneratesCorrectly));
+ var gen = new ComposerGenerator();
+ _ = 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("RequestPipeline.Composer.g.cs", names);
+
+ // Verify the generated source contains 'partial record class'
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("partial record class RequestPipeline", 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 RecordStruct_GeneratesCorrectly()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial record struct RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordStruct_GeneratesCorrectly));
+ var gen = new ComposerGenerator();
+ _ = 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("RequestPipeline.Composer.g.cs", names);
+
+ // Verify the generated source contains 'partial record struct'
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("partial record struct RequestPipeline", 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 OrderingOuterFirst_WrapsCorrectly()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer(WrapOrder = ComposerWrapOrder.OuterFirst)]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response First(in Request req, System.Func next)
+ {
+ System.Console.WriteLine("First - Before");
+ var result = next(req);
+ System.Console.WriteLine("First - After");
+ return result;
+ }
+
+ [ComposeStep(1)]
+ private Response Second(in Request req, System.Func next)
+ {
+ System.Console.WriteLine("Second - Before");
+ var result = next(req);
+ System.Console.WriteLine("Second - After");
+ return result;
+ }
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req)
+ {
+ System.Console.WriteLine("Terminal");
+ return new(200);
+ }
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(OrderingOuterFirst_WrapsCorrectly));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ // No generator diagnostics
+ Assert.All(result.Results, r => Assert.Empty(r.Diagnostics));
+
+ // Verify the generated source
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+
+ // The pipeline should be built from terminal and wrapped by steps
+ Assert.Contains("pipeline", generatedSource);
+ Assert.Contains("First", generatedSource);
+ Assert.Contains("Second", generatedSource);
+ Assert.Contains("Terminal", 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 OrderingInnerFirst_WrapsCorrectly()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer(WrapOrder = ComposerWrapOrder.InnerFirst)]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response First(in Request req, System.Func next)
+ {
+ System.Console.WriteLine("First - Before");
+ var result = next(req);
+ System.Console.WriteLine("First - After");
+ return result;
+ }
+
+ [ComposeStep(1)]
+ private Response Second(in Request req, System.Func next)
+ {
+ System.Console.WriteLine("Second - Before");
+ var result = next(req);
+ System.Console.WriteLine("Second - After");
+ return result;
+ }
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req)
+ {
+ System.Console.WriteLine("Terminal");
+ return new(200);
+ }
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(OrderingInnerFirst_WrapsCorrectly));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ // No generator diagnostics
+ Assert.All(result.Results, r => Assert.Empty(r.Diagnostics));
+
+ // Verify the generated source
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+
+ // The pipeline should be built from terminal and wrapped by steps
+ Assert.Contains("pipeline", generatedSource);
+ Assert.Contains("First", generatedSource);
+ Assert.Contains("Second", generatedSource);
+ Assert.Contains("Terminal", 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 ComposeIgnoreAttribute_SkipsMethod()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step1(in Request req, System.Func next) => next(req);
+
+ [ComposeStep(1)]
+ [ComposeIgnore]
+ private Response Step2(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ComposeIgnoreAttribute_SkipsMethod));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ // No generator diagnostics
+ Assert.All(result.Results, r => Assert.Empty(r.Diagnostics));
+
+ // Verify the generated source
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+
+ // Should only have pipeline using Step1, not Step2
+ Assert.Contains("Step1", generatedSource);
+ Assert.DoesNotContain("Step2", 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 CustomInvokeMethodName_GeneratesCorrectly()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer(InvokeMethodName = "Execute")]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(CustomInvokeMethodName_GeneratesCorrectly));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ // No generator diagnostics
+ Assert.All(result.Results, r => Assert.Empty(r.Diagnostics));
+
+ // Verify the generated source contains custom method name
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("public global::PatternKit.Examples.Response Execute(in global::PatternKit.Examples.Request input)", generatedSource);
+ Assert.DoesNotContain("public global::PatternKit.Examples.Response Invoke(in global::PatternKit.Examples.Request input)", 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 SyncPipelineWithForceAsync_GeneratesBoth()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer(ForceAsync = true)]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response SyncStep(in Request req, System.Func next)
+ {
+ System.Console.WriteLine("Sync step");
+ return next(req);
+ }
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(SyncPipelineWithForceAsync_GeneratesBoth));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ // No generator diagnostics
+ Assert.All(result.Results, r => Assert.Empty(r.Diagnostics));
+
+ // Verify the generated source contains both Invoke and InvokeAsync
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("public global::PatternKit.Examples.Response Invoke(in global::PatternKit.Examples.Request input)", generatedSource);
+ Assert.Contains("InvokeAsync", 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 AsyncStruct_GeneratesCorrectly()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial struct AsyncRequestPipeline
+ {
+ [ComposeStep(0)]
+ private async ValueTask AuthAsync(Request req, Func> next, CancellationToken ct)
+ {
+ await Task.Delay(10, ct);
+ if (req.Path == "/forbidden")
+ return new Response(403);
+ return await next(req);
+ }
+
+ [ComposeTerminal]
+ private ValueTask TerminalAsync(Request req, CancellationToken ct) =>
+ new ValueTask(new Response(200));
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(AsyncStruct_GeneratesCorrectly));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ // No generator diagnostics
+ Assert.All(result.Results, r => Assert.Empty(r.Diagnostics));
+
+ // Verify the generated source contains InvokeAsync method
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("InvokeAsync", generatedSource);
+ Assert.Contains("ValueTask", 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 InvalidStepSignature_ProducesDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, int invalidNext) // Invalid signature - not a Func
+ {
+ return new Response(200);
+ }
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InvalidStepSignature_ProducesDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ // Should have PKCOM006 diagnostic
+ var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToList();
+ Assert.NotEmpty(diagnostics);
+ Assert.Contains(diagnostics, d => d.Id == "PKCOM006");
+ }
+
+ [Fact]
+ public void InvalidTerminalSignature_ProducesDiagnostic()
+ {
+ var source = """
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response Step(in Request req, System.Func next) => next(req);
+
+ [ComposeTerminal]
+ private Response Terminal() // Invalid signature - no input parameter
+ {
+ return new Response(200);
+ }
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InvalidTerminalSignature_ProducesDiagnostic));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out _);
+
+ // Should have PKCOM007 diagnostic
+ var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToList();
+ Assert.NotEmpty(diagnostics);
+ Assert.Contains(diagnostics, d => d.Id == "PKCOM007");
+ }
+
+ [Fact]
+ public void TrulyMixedSyncAndAsync_GeneratesAsyncOnly()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using PatternKit.Generators.Composer;
+
+ namespace PatternKit.Examples;
+
+ public readonly record struct Request(string Path);
+ public readonly record struct Response(int Status);
+
+ [Composer]
+ public partial class RequestPipeline
+ {
+ [ComposeStep(0)]
+ private Response SyncStep(in Request req, System.Func next)
+ {
+ System.Console.WriteLine("Sync step");
+ return next(req);
+ }
+
+ [ComposeStep(1)]
+ private async ValueTask AsyncStep(Request req, Func> next, CancellationToken ct)
+ {
+ await Task.Delay(10, ct);
+ return await next(req);
+ }
+
+ [ComposeTerminal]
+ private Response Terminal(in Request req) => new(200);
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(TrulyMixedSyncAndAsync_GeneratesAsyncOnly));
+ var gen = new ComposerGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ // No generator diagnostics
+ Assert.All(result.Results, r => Assert.Empty(r.Diagnostics));
+
+ // Verify the generated source contains InvokeAsync (mixed scenario only generates async)
+ var generatedSource = result.Results[0].GeneratedSources[0].SourceText.ToString();
+ Assert.Contains("InvokeAsync", generatedSource);
+
+ // And the updated compilation actually compiles
+ var emit = updated.Emit(Stream.Null);
+ Assert.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
+}
diff --git a/test/PatternKit.Generators.Tests/packages.lock.json b/test/PatternKit.Generators.Tests/packages.lock.json
index 96876dc..e8f4cbc 100644
--- a/test/PatternKit.Generators.Tests/packages.lock.json
+++ b/test/PatternKit.Generators.Tests/packages.lock.json
@@ -386,7 +386,10 @@
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.0.1",
- "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ=="
+ "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==",
+ "dependencies": {
+ "System.Reflection.Metadata": "8.0.0"
+ }
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
@@ -407,6 +410,14 @@
"resolved": "10.0.2",
"contentHash": "TJPpTLF5MFPobq09c9BQ5X8QuviQfsKvH0Jbm7MkGylGvfIdRqJQLZDPC5sMRFkk9aZhmgir1NJKekip2NxfaA=="
},
+ "System.Reflection.Metadata": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==",
+ "dependencies": {
+ "System.Collections.Immutable": "8.0.0"
+ }
+ },
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
@@ -469,7 +480,8 @@
"patternkit.generators": {
"type": "Project",
"dependencies": {
- "PatternKit.Generators.Abstractions": "[1.0.0, )"
+ "PatternKit.Generators.Abstractions": "[1.0.0, )",
+ "System.Collections.Immutable": "[10.0.2, )"
}
},
"patternkit.generators.abstractions": {