diff --git a/docs/generators/composer.md b/docs/generators/composer.md index 3d34ad9e..bc7ee54d 100644 --- a/docs/generators/composer.md +++ b/docs/generators/composer.md @@ -116,7 +116,7 @@ Main attribute for marking pipeline host types. |---|---|---|---| | `InvokeMethodName` | `string` | `"Invoke"` | Name of generated sync method | | `InvokeAsyncMethodName` | `string` | `"InvokeAsync"` | Name of generated async method | -| `GenerateAsync` | `bool?` | `null` | Explicit async control; null = infer from steps | +| `GenerateAsync` | `bool` | Inferred when omitted | Explicit async control | | `ForceAsync` | `bool` | `false` | Force async generation even if all steps are sync | | `WrapOrder` | `ComposerWrapOrder` | `OuterFirst` | Determines wrapping order | diff --git a/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs b/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs index 484586f0..d1a00cd2 100644 --- a/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs +++ b/src/PatternKit.Generators.Abstractions/Composer/ComposerAttribute.cs @@ -21,11 +21,10 @@ public sealed class ComposerAttribute : Attribute /// /// 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. + /// When omitted, async generation is inferred from the presence of async steps or terminal. + /// Set to true/false explicitly to control async generation. /// - public bool? GenerateAsync { get; set; } + public bool GenerateAsync { get; set; } /// /// Gets or sets whether to force async generation even if all steps are synchronous. diff --git a/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs b/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs index 369aa9f8..4ebb3c2d 100644 --- a/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs @@ -1111,4 +1111,150 @@ private Response Terminal(in Request req) ScenarioExpect.Equal("PKCOM006", diagnostic.Id); ScenarioExpect.Contains("Step", diagnostic.GetMessage(), StringComparison.Ordinal); } + + [Scenario("GenerateAsyncFalse WithAsyncStep ReportsDiagnostic")] + [Fact] + public void GenerateAsyncFalse_WithAsyncStep_ReportsDiagnostic() + { + var source = """ + using System; + using System.Threading.Tasks; + using PatternKit.Generators.Composer; + + public readonly record struct Request(string Path); + public readonly record struct Response(int Status); + + [Composer(GenerateAsync = false)] + public partial class RequestPipeline + { + [ComposeStep(0)] + private ValueTask StepAsync(Request req, Func> next) + => next(req); + + [ComposeTerminal] + private Response Terminal(in Request req) => new(200); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAsyncFalse_WithAsyncStep_ReportsDiagnostic)); + var gen = new ComposerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostic = ScenarioExpect.Single(result.Results.SelectMany(r => r.Diagnostics)); + ScenarioExpect.Equal("PKCOM008", diagnostic.Id); + ScenarioExpect.Contains("StepAsync", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + [Scenario("NamedOrderAndIgnoredStep GenerateDeterministicPipeline")] + [Fact] + public void NamedOrderAndIgnoredStep_GenerateDeterministicPipeline() + { + var source = """ + using System; + using PatternKit.Generators.Composer; + + public readonly record struct Request(string Path); + public readonly record struct Response(int Status); + + [Composer] + public partial class RequestPipeline + { + [ComposeStep(99, Order = 2, Name = "Audit")] + private Response Audit(in Request req, Func next) => next(req); + + [ComposeStep(1)] + private Response Auth(in Request req, Func next) => next(req); + + [ComposeStep(0)] + [ComposeIgnore] + private Response Ignored(in Request req, Func next) => next(req); + + [ComposeTerminal] + private Response Terminal(in Request req) => new(200); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(NamedOrderAndIgnoredStep_GenerateDeterministicPipeline)); + var gen = new ComposerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + + var generatedSource = result.Results.SelectMany(r => r.GeneratedSources).Single().SourceText.ToString(); + ScenarioExpect.Contains("Audit(in arg, pipeline)", generatedSource); + ScenarioExpect.Contains("Auth(in arg, pipeline)", generatedSource); + ScenarioExpect.DoesNotContain("Ignored", generatedSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("StructAsyncTerminalWithoutCancellationToken GeneratesAsyncPipeline")] + [Fact] + public void StructAsyncTerminalWithoutCancellationToken_GeneratesAsyncPipeline() + { + var source = """ + using System; + using System.Threading.Tasks; + using PatternKit.Generators.Composer; + + public readonly record struct Request(string Path); + public readonly record struct Response(int Status); + + [Composer] + public partial struct RequestPipeline + { + [ComposeStep(0)] + private Response Audit(in Request req, Func next) => next(req); + + [ComposeTerminal] + private ValueTask TerminalAsync(Request req) + => new(new Response(200)); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StructAsyncTerminalWithoutCancellationToken_GeneratesAsyncPipeline)); + var gen = new ComposerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + + var generatedSource = result.Results.SelectMany(r => r.GeneratedSources).Single().SourceText.ToString(); + ScenarioExpect.Contains("terminalFunc", generatedSource); + ScenarioExpect.Contains("self.TerminalAsync(arg)", generatedSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("InvalidStep WithFunctionPointerNext ReportsDiagnostic")] + [Fact] + public void InvalidStep_WithFunctionPointerNext_ReportsDiagnostic() + { + var source = """ + using PatternKit.Generators.Composer; + + public readonly record struct Request(string Path); + public readonly record struct Response(int Status); + + [Composer] + public partial class RequestPipeline + { + [ComposeStep(0)] + private unsafe Response Step(in Request req, delegate* next) + => new(200); + + [ComposeTerminal] + private Response Terminal(in Request req) => new(200); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InvalidStep_WithFunctionPointerNext_ReportsDiagnostic)); + var gen = new ComposerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostic = ScenarioExpect.Single(result.Results.SelectMany(r => r.Diagnostics)); + ScenarioExpect.Equal("PKCOM006", diagnostic.Id); + ScenarioExpect.Contains("Step", diagnostic.GetMessage(), StringComparison.Ordinal); + } }