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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/generators/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@ public sealed class ComposerAttribute : Attribute

/// <summary>
/// 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.
/// </summary>
Comment on lines 22 to 26
public bool? GenerateAsync { get; set; }
public bool GenerateAsync { get; set; }
Comment on lines 23 to +27

/// <summary>
/// Gets or sets whether to force async generation even if all steps are synchronous.
Expand Down
146 changes: 146 additions & 0 deletions test/PatternKit.Generators.Tests/ComposerGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> StepAsync(Request req, Func<Request, ValueTask<Response>> 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<Request, Response> next) => next(req);

[ComposeStep(1)]
private Response Auth(in Request req, Func<Request, Response> next) => next(req);

[ComposeStep(0)]
[ComposeIgnore]
private Response Ignored(in Request req, Func<Request, Response> 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<Request, Response> next) => next(req);

[ComposeTerminal]
private ValueTask<Response> 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*<Request, Response> 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();
Comment on lines +1252 to +1253
_ = 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);
}
}
Loading