From 39989ab9a464d23d1cd6f7d490a023fbcd04a9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=BD=E7=AC=94wys?= <46271592+Skymly@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:55:56 +0800 Subject: [PATCH] Emit BuildAsync when assemble returns Task or ValueTask. Keep the required-step type-state gate for I/O assemble without a sync-over-async Build. Co-authored-by: Cursor --- CHANGELOG.md | 1 + .../Generators/GenerateBuilderGenerator.cs | 76 ++++++- .../Generators/GenerateBuilderModels.cs | 22 +- .../Syntax/GenerateBuilderSyntaxFactory.cs | 19 +- ...TokenFromBuildAsyncToAssemble.verified.txt | 77 +++++++ ...yncWhenAssembleReturnsTaskOfT.verified.txt | 77 +++++++ ...enAssembleReturnsValueTaskOfT.verified.txt | 77 +++++++ ...HasMultipleCancellationTokens.verified.txt | 6 + ...Dp086WhenAssembleIsDuplicated.verified.txt | 6 + ...86WhenAssembleReturnsBareTask.verified.txt | 6 + ...nAssembleReturnsBareValueTask.verified.txt | 6 + .../GenerateBuilderGeneratorTests.cs | 211 +++++++++++++++++- 12 files changed, 568 insertions(+), 16 deletions(-) create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ForwardsCancellationTokenFromBuildAsyncToAssemble.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsTaskOfT.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsValueTaskOfT.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleHasMultipleCancellationTokens.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleIsDuplicated.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareTask.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareValueTask.verified.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 41143c6..c292414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Work Graph generator**: `[WorkGraph]` / `[WorkGraph]` + `[WorkStep]` drive `WorkGraphGenerator`, emitting `{Holder}WorkStepKeys` and `{Holder}WorkGraph.Create(resolver|dictionary)` that fills `WorkGraphBuilder`. Reports **DP087–DP092** (cycle, unknown DependsOn, duplicate id, self-dependency, unreachable Warning, contract mismatch); no MVP DI/Autofac emission ([#311](https://github.com/Skymly/DesignPatterns/issues/311), Spec [#308](https://github.com/Skymly/DesignPatterns/issues/308)). - **Work Graph diagnostic IDs**: **DP087–DP092** for the Work Graph generator (dependency cycle, unknown DependsOn, duplicate step id, self-dependency, unreachable step Warning, contract/TContext mismatch); no unregistered-`IWorkStep` Analyzer in MVP; DP067–DP071 remain ADR-008-only ([#310](https://github.com/Skymly/DesignPatterns/issues/310), Spec [#308](https://github.com/Skymly/DesignPatterns/issues/308)). - **Work Graph runtime**: `IWorkStep` / `IWorkGraph` / `WorkGraphBuilder` with topological wave execution and fail-fast cancellation; `[WorkGraph]` / `[WorkGraph]` / `[WorkStep]` attributes; empty/cycle/duplicate/self/unknown DAGs throw `InvalidWorkGraphException` at `Build` ([#309](https://github.com/Skymly/DesignPatterns/issues/309), Spec [#308](https://github.com/Skymly/DesignPatterns/issues/308)). +- **Step Builder async assemble**: `[BuilderAssemble]` returning `Task` or `ValueTask` emits type-state–gated `BuildAsync(CancellationToken cancellationToken = default)` instead of `Build()`; optional assemble `CancellationToken` is forwarded and excluded from step binding. Bare `Task`/`ValueTask`, duplicate assemble, and multiple `CancellationToken` parameters report **DP086** ([#326](https://github.com/Skymly/DesignPatterns/issues/326); Relates to [#324](https://github.com/Skymly/DesignPatterns/issues/324)). ### Changed diff --git a/DesignPatterns.SourceGenerators/Generators/GenerateBuilderGenerator.cs b/DesignPatterns.SourceGenerators/Generators/GenerateBuilderGenerator.cs index fc52eba..369fc8f 100644 --- a/DesignPatterns.SourceGenerators/Generators/GenerateBuilderGenerator.cs +++ b/DesignPatterns.SourceGenerators/Generators/GenerateBuilderGenerator.cs @@ -96,20 +96,23 @@ private static Result Transform(GeneratorAttributeSyntaxCo return Result.Failure(diagnostics); } + var compilation = context.SemanticModel.Compilation; + var assembleMethod = assembleMethods[0]; if (assembleMethods.Count > 1 - || assembleMethods[0].ReturnsVoid - || assembleMethods[0].ReturnType.SpecialType == SpecialType.System_Void) + || assembleMethod.ReturnsVoid + || assembleMethod.ReturnType.SpecialType == SpecialType.System_Void + || IsBareTaskOrValueTask(assembleMethod.ReturnType, compilation) + || CountCancellationTokens(assembleMethod, compilation) > 1) { - var assemble = assembleMethods[0]; diagnostics.Add(new DiagnosticInfo( DesignPatternsDiagnosticDescriptors.GenerateBuilderAssembleContractMismatch, - new LocationInfo(assemble.Locations.FirstOrDefault()), - assemble.Name, + new LocationInfo(assembleMethod.Locations.FirstOrDefault()), + assembleMethod.Name, holder.Name)); return Result.Failure(diagnostics); } - var assembleMethod = assembleMethods[0]; + var assembleIsAsync = IsAsyncAssembleReturn(assembleMethod.ReturnType, compilation); if (!assembleMethod.IsStatic) { // Generated code lives in the consumer assembly, so the ctor must be accessible. @@ -182,8 +185,20 @@ private static Result Transform(GeneratorAttributeSyntaxCo ValidatePartialOrder(holder.Name, steps, diagnostics); var assembleParameters = new List(); + var cancellationTokenType = compilation.GetTypeByMetadataName("System.Threading.CancellationToken"); foreach (var parameter in assembleMethod.Parameters) { + if (assembleIsAsync + && cancellationTokenType is not null + && SymbolEqualityComparer.Default.Equals(parameter.Type, cancellationTokenType)) + { + assembleParameters.Add(new BuilderAssembleParameterModel( + parameter.Name, + boundStepMethodName: null, + isCancellationToken: true)); + continue; + } + var match = FindStepForParameter(parameter.Name, steps); if (match is null) { @@ -212,6 +227,7 @@ private static Result Transform(GeneratorAttributeSyntaxCo holder.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), namespaceName, assembleMethod.IsStatic, + assembleIsAsync, assembleMethod.Name, assembleMethod.ReturnType.ToDisplayString(TypeDisplayFormat), new EquatableArray(steps.ToArray()), @@ -255,6 +271,54 @@ private static bool IsAccessibleHolder(INamedTypeSymbol holder) return true; } + private static bool IsBareTaskOrValueTask(ITypeSymbol returnType, Compilation compilation) + { + if (returnType is not INamedTypeSymbol namedReturn || namedReturn.IsGenericType) + { + return false; + } + + var original = namedReturn.OriginalDefinition; + var task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task"); + var valueTask = compilation.GetTypeByMetadataName("System.Threading.Tasks.ValueTask"); + return (task is not null && SymbolEqualityComparer.Default.Equals(original, task)) + || (valueTask is not null && SymbolEqualityComparer.Default.Equals(original, valueTask)); + } + + private static int CountCancellationTokens(IMethodSymbol method, Compilation compilation) + { + var cancellationTokenType = compilation.GetTypeByMetadataName("System.Threading.CancellationToken"); + if (cancellationTokenType is null) + { + return 0; + } + + var count = 0; + foreach (var parameter in method.Parameters) + { + if (SymbolEqualityComparer.Default.Equals(parameter.Type, cancellationTokenType)) + { + count++; + } + } + + return count; + } + + private static bool IsAsyncAssembleReturn(ITypeSymbol returnType, Compilation compilation) + { + if (returnType is not INamedTypeSymbol namedReturn) + { + return false; + } + + var taskOfT = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1"); + var valueTaskOfT = compilation.GetTypeByMetadataName("System.Threading.Tasks.ValueTask`1"); + var original = namedReturn.OriginalDefinition; + return (taskOfT is not null && SymbolEqualityComparer.Default.Equals(original, taskOfT)) + || (valueTaskOfT is not null && SymbolEqualityComparer.Default.Equals(original, valueTaskOfT)); + } + private static bool HasAttribute(IMethodSymbol method, string metadataName) => method.GetAttributes().Any(attribute => attribute.AttributeClass is { } attributeClass diff --git a/DesignPatterns.SourceGenerators/Generators/GenerateBuilderModels.cs b/DesignPatterns.SourceGenerators/Generators/GenerateBuilderModels.cs index cccf189..54cbf7e 100644 --- a/DesignPatterns.SourceGenerators/Generators/GenerateBuilderModels.cs +++ b/DesignPatterns.SourceGenerators/Generators/GenerateBuilderModels.cs @@ -69,18 +69,24 @@ public override int GetHashCode() internal readonly struct BuilderAssembleParameterModel : IEquatable { - public BuilderAssembleParameterModel(string parameterName, string boundStepMethodName) + public BuilderAssembleParameterModel( + string parameterName, + string? boundStepMethodName, + bool isCancellationToken = false) { ParameterName = parameterName; BoundStepMethodName = boundStepMethodName; + IsCancellationToken = isCancellationToken; } public string ParameterName { get; } - public string BoundStepMethodName { get; } + public string? BoundStepMethodName { get; } + public bool IsCancellationToken { get; } public bool Equals(BuilderAssembleParameterModel other) => string.Equals(ParameterName, other.ParameterName, StringComparison.Ordinal) - && string.Equals(BoundStepMethodName, other.BoundStepMethodName, StringComparison.Ordinal); + && string.Equals(BoundStepMethodName, other.BoundStepMethodName, StringComparison.Ordinal) + && IsCancellationToken == other.IsCancellationToken; public override bool Equals(object? obj) => obj is BuilderAssembleParameterModel other && Equals(other); @@ -88,8 +94,9 @@ public override int GetHashCode() { unchecked { - return (StringComparer.Ordinal.GetHashCode(ParameterName ?? string.Empty) * 31) - + StringComparer.Ordinal.GetHashCode(BoundStepMethodName ?? string.Empty); + var hash = StringComparer.Ordinal.GetHashCode(ParameterName ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(BoundStepMethodName ?? string.Empty); + return (hash * 31) + IsCancellationToken.GetHashCode(); } } } @@ -101,6 +108,7 @@ public GenerateBuilderModel( string holderFullyQualifiedName, string? namespaceName, bool assembleIsStatic, + bool assembleIsAsync, string assembleMethodName, string productTypeDisplay, EquatableArray steps, @@ -111,6 +119,7 @@ public GenerateBuilderModel( HolderFullyQualifiedName = holderFullyQualifiedName; NamespaceName = namespaceName; AssembleIsStatic = assembleIsStatic; + AssembleIsAsync = assembleIsAsync; AssembleMethodName = assembleMethodName; ProductTypeDisplay = productTypeDisplay; Steps = steps; @@ -122,6 +131,7 @@ public GenerateBuilderModel( public string HolderFullyQualifiedName { get; } public string? NamespaceName { get; } public bool AssembleIsStatic { get; } + public bool AssembleIsAsync { get; } public string AssembleMethodName { get; } public string ProductTypeDisplay { get; } public EquatableArray Steps { get; } @@ -141,6 +151,7 @@ public bool Equals(GenerateBuilderModel? other) && string.Equals(HolderFullyQualifiedName, other.HolderFullyQualifiedName, StringComparison.Ordinal) && string.Equals(NamespaceName, other.NamespaceName, StringComparison.Ordinal) && AssembleIsStatic == other.AssembleIsStatic + && AssembleIsAsync == other.AssembleIsAsync && string.Equals(AssembleMethodName, other.AssembleMethodName, StringComparison.Ordinal) && string.Equals(ProductTypeDisplay, other.ProductTypeDisplay, StringComparison.Ordinal) && Steps.Equals(other.Steps) @@ -158,6 +169,7 @@ public override int GetHashCode() hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(HolderFullyQualifiedName ?? string.Empty); hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(NamespaceName ?? string.Empty); hash = (hash * 31) + AssembleIsStatic.GetHashCode(); + hash = (hash * 31) + AssembleIsAsync.GetHashCode(); hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(AssembleMethodName ?? string.Empty); hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ProductTypeDisplay ?? string.Empty); hash = (hash * 31) + Steps.GetHashCode(); diff --git a/DesignPatterns.SourceGenerators/Syntax/GenerateBuilderSyntaxFactory.cs b/DesignPatterns.SourceGenerators/Syntax/GenerateBuilderSyntaxFactory.cs index 28a8809..298feb0 100644 --- a/DesignPatterns.SourceGenerators/Syntax/GenerateBuilderSyntaxFactory.cs +++ b/DesignPatterns.SourceGenerators/Syntax/GenerateBuilderSyntaxFactory.cs @@ -182,8 +182,9 @@ private static void AppendExtensions( string stateType, string completeBuilderType) { + var exitName = model.AssembleIsAsync ? "BuildAsync" : "Build"; sb.Append(indent).AppendLine("/// "); - sb.Append(indent).AppendLine($"/// Step and Build extensions for {model.BuilderName}."); + sb.Append(indent).AppendLine($"/// Step and {exitName} extensions for {model.BuilderName}."); sb.Append(indent).AppendLine("/// "); sb.Append(indent).AppendLine("[global::System.CodeDom.Compiler.GeneratedCode(\"DesignPatterns.SourceGenerators\", \"1.0\")]"); sb.Append(indent).AppendLine($"public static class {model.BuilderName}Extensions"); @@ -361,18 +362,28 @@ private static void AppendBuildMethod( { var stepByMethod = steps.ToDictionary(static s => s.MethodName, StringComparer.Ordinal); + var signature = model.AssembleIsAsync + ? $" public static {model.ProductTypeDisplay} BuildAsync(this {completeBuilderType} builder, global::System.Threading.CancellationToken cancellationToken = default)" + : $" public static {model.ProductTypeDisplay} Build(this {completeBuilderType} builder)"; + sb.Append(indent).AppendLine(" /// "); sb.Append(indent).AppendLine($" /// Builds the product by invoking {model.HolderName}.{model.AssembleMethodName}."); sb.Append(indent).AppendLine(" /// "); - sb.Append(indent).AppendLine( - $" public static {model.ProductTypeDisplay} Build(this {completeBuilderType} builder)"); + sb.Append(indent).AppendLine(signature); sb.Append(indent).AppendLine(" {"); sb.Append(indent).AppendLine(" var state = builder.State;"); var args = new List(); foreach (var parameter in model.AssembleParameters) { - if (!stepByMethod.TryGetValue(parameter.BoundStepMethodName, out var step)) + if (parameter.IsCancellationToken) + { + args.Add("cancellationToken"); + continue; + } + + if (parameter.BoundStepMethodName is null + || !stepByMethod.TryGetValue(parameter.BoundStepMethodName, out var step)) { args.Add("default"); continue; diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ForwardsCancellationTokenFromBuildAsyncToAssemble.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ForwardsCancellationTokenFromBuildAsyncToAssemble.verified.txt new file mode 100644 index 0000000..ecf5484 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ForwardsCancellationTokenFromBuildAsyncToAssemble.verified.txt @@ -0,0 +1,77 @@ +{ + TestAssembly_CancellableAssembleSchema.Builder.g.cs: +// +// Generated by DesignPatterns.SourceGenerators.GenerateBuilderGenerator +#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618 +using System; +using System.Collections.Generic; + +namespace TestAssembly +{ +#nullable enable + /// + /// Fluent step builder entry point for CancellableAssembleSchema. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static class CancellableAssembleSchemaBuilder + { + /// + /// Creates a new builder with no required steps applied. + /// + public static CancellableAssembleSchemaBuilder Create() => new CancellableAssembleSchemaBuilder(new CancellableAssembleSchemaBuilderState()); + } + + /// + /// Typed step builder for CancellableAssembleSchema. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public sealed class CancellableAssembleSchemaBuilder + { + private readonly CancellableAssembleSchemaBuilderState _state; + internal CancellableAssembleSchemaBuilder(CancellableAssembleSchemaBuilderState state) => _state = state; + internal CancellableAssembleSchemaBuilderState State => _state; + } + + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + internal sealed class CancellableAssembleSchemaBuilderState + { + public string? Url; + public bool UrlSet; + public List AppliedOrder = new List(); + public CancellableAssembleSchemaBuilderState Clone() + { + var clone = (CancellableAssembleSchemaBuilderState)MemberwiseClone(); + clone.AppliedOrder = new List(AppliedOrder); + return clone; + } + } + + /// + /// Step and BuildAsync extensions for CancellableAssembleSchemaBuilder. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static class CancellableAssembleSchemaBuilderExtensions + { + /// + /// Applies the required 'WithUrl' step. + /// + public static CancellableAssembleSchemaBuilder WithUrl(this CancellableAssembleSchemaBuilder builder, string url) + { + var state = builder.State.Clone(); + state.Url = url; + state.UrlSet = true; + state.AppliedOrder.Add("WithUrl"); + return new CancellableAssembleSchemaBuilder(state); + } + + /// + /// Builds the product by invoking CancellableAssembleSchema.Assemble. + /// + public static global::System.Threading.Tasks.Task BuildAsync(this CancellableAssembleSchemaBuilder builder, global::System.Threading.CancellationToken cancellationToken = default) + { + var state = builder.State; + return global::TestAssembly.CancellableAssembleSchema.Assemble(state.Url!, cancellationToken); + } + } +} +} \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsTaskOfT.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsTaskOfT.verified.txt new file mode 100644 index 0000000..304846a --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsTaskOfT.verified.txt @@ -0,0 +1,77 @@ +{ + TestAssembly_AsyncHttpRequestSchema.Builder.g.cs: +// +// Generated by DesignPatterns.SourceGenerators.GenerateBuilderGenerator +#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618 +using System; +using System.Collections.Generic; + +namespace TestAssembly +{ +#nullable enable + /// + /// Fluent step builder entry point for AsyncHttpRequestSchema. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static class AsyncHttpRequestSchemaBuilder + { + /// + /// Creates a new builder with no required steps applied. + /// + public static AsyncHttpRequestSchemaBuilder Create() => new AsyncHttpRequestSchemaBuilder(new AsyncHttpRequestSchemaBuilderState()); + } + + /// + /// Typed step builder for AsyncHttpRequestSchema. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public sealed class AsyncHttpRequestSchemaBuilder + { + private readonly AsyncHttpRequestSchemaBuilderState _state; + internal AsyncHttpRequestSchemaBuilder(AsyncHttpRequestSchemaBuilderState state) => _state = state; + internal AsyncHttpRequestSchemaBuilderState State => _state; + } + + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + internal sealed class AsyncHttpRequestSchemaBuilderState + { + public string? Url; + public bool UrlSet; + public List AppliedOrder = new List(); + public AsyncHttpRequestSchemaBuilderState Clone() + { + var clone = (AsyncHttpRequestSchemaBuilderState)MemberwiseClone(); + clone.AppliedOrder = new List(AppliedOrder); + return clone; + } + } + + /// + /// Step and BuildAsync extensions for AsyncHttpRequestSchemaBuilder. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static class AsyncHttpRequestSchemaBuilderExtensions + { + /// + /// Applies the required 'WithUrl' step. + /// + public static AsyncHttpRequestSchemaBuilder WithUrl(this AsyncHttpRequestSchemaBuilder builder, string url) + { + var state = builder.State.Clone(); + state.Url = url; + state.UrlSet = true; + state.AppliedOrder.Add("WithUrl"); + return new AsyncHttpRequestSchemaBuilder(state); + } + + /// + /// Builds the product by invoking AsyncHttpRequestSchema.Assemble. + /// + public static global::System.Threading.Tasks.Task BuildAsync(this AsyncHttpRequestSchemaBuilder builder, global::System.Threading.CancellationToken cancellationToken = default) + { + var state = builder.State; + return global::TestAssembly.AsyncHttpRequestSchema.Assemble(state.Url!); + } + } +} +} \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsValueTaskOfT.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsValueTaskOfT.verified.txt new file mode 100644 index 0000000..fd55cca --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.GeneratesBuildAsyncWhenAssembleReturnsValueTaskOfT.verified.txt @@ -0,0 +1,77 @@ +{ + TestAssembly_ValueTaskHttpRequestSchema.Builder.g.cs: +// +// Generated by DesignPatterns.SourceGenerators.GenerateBuilderGenerator +#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618 +using System; +using System.Collections.Generic; + +namespace TestAssembly +{ +#nullable enable + /// + /// Fluent step builder entry point for ValueTaskHttpRequestSchema. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static class ValueTaskHttpRequestSchemaBuilder + { + /// + /// Creates a new builder with no required steps applied. + /// + public static ValueTaskHttpRequestSchemaBuilder Create() => new ValueTaskHttpRequestSchemaBuilder(new ValueTaskHttpRequestSchemaBuilderState()); + } + + /// + /// Typed step builder for ValueTaskHttpRequestSchema. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public sealed class ValueTaskHttpRequestSchemaBuilder + { + private readonly ValueTaskHttpRequestSchemaBuilderState _state; + internal ValueTaskHttpRequestSchemaBuilder(ValueTaskHttpRequestSchemaBuilderState state) => _state = state; + internal ValueTaskHttpRequestSchemaBuilderState State => _state; + } + + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + internal sealed class ValueTaskHttpRequestSchemaBuilderState + { + public string? Url; + public bool UrlSet; + public List AppliedOrder = new List(); + public ValueTaskHttpRequestSchemaBuilderState Clone() + { + var clone = (ValueTaskHttpRequestSchemaBuilderState)MemberwiseClone(); + clone.AppliedOrder = new List(AppliedOrder); + return clone; + } + } + + /// + /// Step and BuildAsync extensions for ValueTaskHttpRequestSchemaBuilder. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static class ValueTaskHttpRequestSchemaBuilderExtensions + { + /// + /// Applies the required 'WithUrl' step. + /// + public static ValueTaskHttpRequestSchemaBuilder WithUrl(this ValueTaskHttpRequestSchemaBuilder builder, string url) + { + var state = builder.State.Clone(); + state.Url = url; + state.UrlSet = true; + state.AppliedOrder.Add("WithUrl"); + return new ValueTaskHttpRequestSchemaBuilder(state); + } + + /// + /// Builds the product by invoking ValueTaskHttpRequestSchema.Assemble. + /// + public static global::System.Threading.Tasks.ValueTask BuildAsync(this ValueTaskHttpRequestSchemaBuilder builder, global::System.Threading.CancellationToken cancellationToken = default) + { + var state = builder.State; + return global::TestAssembly.ValueTaskHttpRequestSchema.Assemble(state.Url!); + } + } +} +} \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleHasMultipleCancellationTokens.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleHasMultipleCancellationTokens.verified.txt new file mode 100644 index 0000000..801a679 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleHasMultipleCancellationTokens.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP086, + Message: Method 'Assemble' on holder 'MultipleCancellationTokenSchema' is marked [BuilderAssemble] but does not satisfy the assemble contract (exactly one assemble method returning sync product T, or async Task / ValueTask; not void, bare Task/ValueTask, or multiple CancellationToken parameters). Fix the signature or remove duplicate [BuilderAssemble] attributes. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleIsDuplicated.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleIsDuplicated.verified.txt new file mode 100644 index 0000000..cef4b8e --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleIsDuplicated.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP086, + Message: Method 'Assemble' on holder 'DuplicateAssembleSchema' is marked [BuilderAssemble] but does not satisfy the assemble contract (exactly one assemble method returning sync product T, or async Task / ValueTask; not void, bare Task/ValueTask, or multiple CancellationToken parameters). Fix the signature or remove duplicate [BuilderAssemble] attributes. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareTask.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareTask.verified.txt new file mode 100644 index 0000000..dd3f164 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareTask.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP086, + Message: Method 'Assemble' on holder 'BareTaskAssembleSchema' is marked [BuilderAssemble] but does not satisfy the assemble contract (exactly one assemble method returning sync product T, or async Task / ValueTask; not void, bare Task/ValueTask, or multiple CancellationToken parameters). Fix the signature or remove duplicate [BuilderAssemble] attributes. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareValueTask.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareValueTask.verified.txt new file mode 100644 index 0000000..91c4a5c --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.ReportsDp086WhenAssembleReturnsBareValueTask.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP086, + Message: Method 'Assemble' on holder 'BareValueTaskAssembleSchema' is marked [BuilderAssemble] but does not satisfy the assemble contract (exactly one assemble method returning sync product T, or async Task / ValueTask; not void, bare Task/ValueTask, or multiple CancellationToken parameters). Fix the signature or remove duplicate [BuilderAssemble] attributes. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.cs index 4b9a1f9..981b5af 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateBuilderGeneratorTests.cs @@ -3,7 +3,7 @@ namespace DesignPatterns.SourceGenerators.Tests.Generators; /// -/// Seam: generated {Holder}Builder public API and schema diagnostics (issue #290). +/// Seam: generated {Holder}Builder public API and schema diagnostics (issues #290, #326). /// public sealed class GenerateBuilderGeneratorTests { @@ -60,6 +60,94 @@ public static string Assemble( return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); } + [Fact] + public Task GeneratesBuildAsyncWhenAssembleReturnsTaskOfT() + { + const string source = """ + using System.Threading.Tasks; + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateBuilder] + public static class AsyncHttpRequestSchema + { + [BuilderStep] + public static void WithUrl(string url) + { + } + + [BuilderAssemble] + public static Task Assemble(string url) => + Task.FromResult(url); + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("AsyncHttpRequestSchema.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); + } + + [Fact] + public Task GeneratesBuildAsyncWhenAssembleReturnsValueTaskOfT() + { + const string source = """ + using System.Threading.Tasks; + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateBuilder] + public static class ValueTaskHttpRequestSchema + { + [BuilderStep] + public static void WithUrl(string url) + { + } + + [BuilderAssemble] + public static ValueTask Assemble(string url) => + new ValueTask(url); + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("ValueTaskHttpRequestSchema.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); + } + + [Fact] + public Task ForwardsCancellationTokenFromBuildAsyncToAssemble() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateBuilder] + public static class CancellableAssembleSchema + { + [BuilderStep] + public static void WithUrl(string url) + { + } + + [BuilderAssemble] + public static Task Assemble(string url, CancellationToken cancellationToken) => + Task.FromResult(url); + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("CancellableAssembleSchema.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); + } + [Fact] public Task ReportsDp079WhenAssembleIsMissing() { @@ -355,4 +443,125 @@ public static void Assemble(string name) return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); } + + [Fact] + public Task ReportsDp086WhenAssembleReturnsBareTask() + { + const string source = """ + using System.Threading.Tasks; + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateBuilder] + public static class BareTaskAssembleSchema + { + [BuilderStep] + public static void WithName(string name) + { + } + + [BuilderAssemble] + public static Task Assemble(string name) => + Task.CompletedTask; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("BareTaskAssembleSchema.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp086WhenAssembleReturnsBareValueTask() + { + const string source = """ + using System.Threading.Tasks; + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateBuilder] + public static class BareValueTaskAssembleSchema + { + [BuilderStep] + public static void WithName(string name) + { + } + + [BuilderAssemble] + public static ValueTask Assemble(string name) => + default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("BareValueTaskAssembleSchema.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp086WhenAssembleIsDuplicated() + { + const string source = """ + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateBuilder] + public static class DuplicateAssembleSchema + { + [BuilderStep] + public static void WithName(string name) + { + } + + [BuilderAssemble] + public static string Assemble(string name) => name; + + [BuilderAssemble] + public static string AssembleAsync(string name) => name; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("DuplicateAssembleSchema.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp086WhenAssembleHasMultipleCancellationTokens() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateBuilder] + public static class MultipleCancellationTokenSchema + { + [BuilderStep] + public static void WithUrl(string url) + { + } + + [BuilderAssemble] + public static Task Assemble( + string url, + CancellationToken first, + CancellationToken second) => + Task.FromResult(url); + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("MultipleCancellationTokenSchema.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } }