diff --git a/src/PatternKit.Generators/Messaging/PipesAndFiltersPipelineGenerator.cs b/src/PatternKit.Generators/Messaging/PipesAndFiltersPipelineGenerator.cs index c632b774..63d470f1 100644 --- a/src/PatternKit.Generators/Messaging/PipesAndFiltersPipelineGenerator.cs +++ b/src/PatternKit.Generators/Messaging/PipesAndFiltersPipelineGenerator.cs @@ -76,6 +76,49 @@ private static string GenerateSource( sb.AppendLine(); } + var containingTypes = GetContainingTypes(type); + var indentLevel = 0; + foreach (var containingType in containingTypes) + { + AppendTypeDeclaration(sb, containingType, indentLevel); + sb.AppendLine(); + sb.AppendLine(new string(' ', indentLevel * 4) + "{"); + indentLevel++; + } + + AppendTypeDeclaration(sb, type, indentLevel); + var indent = new string(' ', indentLevel * 4); + sb.AppendLine(); + sb.AppendLine(indent + "{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Messaging.PipesAndFilters.PipesAndFiltersPipeline<").Append(contextTypeName).Append(">.Builder ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Messaging.PipesAndFilters.PipesAndFiltersPipeline<").Append(contextTypeName).Append(">.Create(\"").Append(Escape(pipelineName)).AppendLine("\");"); + sb.AppendLine(memberIndent + "}"); + sb.AppendLine(indent + "}"); + for (var i = containingTypes.Length - 1; i >= 0; i--) + { + sb.AppendLine(new string(' ', i * 4) + "}"); + } + + return sb.ToString(); + } + + private static INamedTypeSymbol[] GetContainingTypes(INamedTypeSymbol type) + { + var containingTypes = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + { + containingTypes.Push(current); + } + + return containingTypes.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, int indentLevel) + { + sb.Append(new string(' ', indentLevel * 4)); sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); if (type.IsStatic) sb.Append("static "); @@ -83,14 +126,7 @@ private static string GenerateSource( sb.Append("abstract "); else if (type.IsSealed && type.TypeKind == TypeKind.Class) sb.Append("sealed "); - sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); - sb.AppendLine("{"); - sb.Append(" public static global::PatternKit.Messaging.PipesAndFilters.PipesAndFiltersPipeline<").Append(contextTypeName).Append(">.Builder ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" return global::PatternKit.Messaging.PipesAndFilters.PipesAndFiltersPipeline<").Append(contextTypeName).Append(">.Create(\"").Append(Escape(pipelineName)).AppendLine("\");"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name); } private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); diff --git a/test/PatternKit.Generators.Tests/PipesAndFiltersPipelineGeneratorTests.cs b/test/PatternKit.Generators.Tests/PipesAndFiltersPipelineGeneratorTests.cs index c0c31d64..7e579643 100644 --- a/test/PatternKit.Generators.Tests/PipesAndFiltersPipelineGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/PipesAndFiltersPipelineGeneratorTests.cs @@ -42,6 +42,94 @@ public static class PipelineHost; ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKPF001")) .AssertPassed(); + [Scenario("Generates pipes and filters host defaults and type shapes")] + [Fact] + public Task Generates_Pipes_And_Filters_Host_Defaults_And_Type_Shapes() + => Given("pipeline declarations using default names and different host shapes", () => Compile(""" + using PatternKit.Generators.Messaging; + + namespace Demo; + + public sealed record FulfillmentContext(string OrderId); + + [GeneratePipesAndFiltersPipeline(typeof(FulfillmentContext))] + internal abstract partial class AbstractPipeline; + + [GeneratePipesAndFiltersPipeline(typeof(FulfillmentContext), PipelineName = "tenant\\\"pipeline")] + public sealed partial class SealedPipeline; + + [GeneratePipesAndFiltersPipeline(typeof(FulfillmentContext))] + internal partial struct StructPipeline; + """)) + .Then("generated sources preserve host shape and configured names", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("internal abstract partial class AbstractPipeline", combined); + ScenarioExpect.Contains("Create()", combined); + ScenarioExpect.Contains("Create(\"pipes-and-filters\")", combined); + ScenarioExpect.Contains("public sealed partial class SealedPipeline", combined); + ScenarioExpect.Contains("Create(\"tenant\\\\\\\"pipeline\")", combined); + ScenarioExpect.Contains("internal partial struct StructPipeline", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested pipes and filters host wrappers")] + [Fact] + public Task Generates_Nested_Pipes_And_Filters_Host_Wrappers() + => Given("nested pipeline declarations with non-public accessibility", () => Compile(""" + using PatternKit.Generators.Messaging; + + namespace Demo; + + public sealed record FulfillmentContext(string OrderId); + + public partial class PipelineContainer + { + private partial class PrivateHost + { + [GeneratePipesAndFiltersPipeline(typeof(FulfillmentContext))] + protected partial class ProtectedPipeline; + + [GeneratePipesAndFiltersPipeline(typeof(FulfillmentContext))] + private protected partial class PrivateProtectedPipeline; + + [GeneratePipesAndFiltersPipeline(typeof(FulfillmentContext))] + protected internal partial class ProtectedInternalPipeline; + } + } + """)) + .Then("generated sources preserve containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); + + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("public partial class PipelineContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedPipeline", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedPipeline", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalPipeline", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Skips malformed pipes and filters type arguments")] + [Fact] + public Task Skips_Malformed_Pipes_And_Filters_Type_Arguments() + => Given("a pipeline declaration with a null context type", () => Compile(""" + using PatternKit.Generators.Messaging; + + [GeneratePipesAndFiltersPipeline(null!)] + public static partial class BrokenPipeline; + """)) + .Then("no source is generated", result => + ScenarioExpect.Empty(result.GeneratedSources)) + .AssertPassed(); + private static GeneratorResult Compile(string source) { var compilation = RoslynTestHelpers.CreateCompilation(