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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Work Graph generator**: `[WorkGraph]` / `[WorkGraph<TContext>]` + `[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<TContext>` / `IWorkGraph<TContext>` / `WorkGraphBuilder<TContext>` with topological wave execution and fail-fast cancellation; `[WorkGraph]` / `[WorkGraph<TContext>]` / `[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<T>` or `ValueTask<T>` 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,23 @@ private static Result<GenerateBuilderModel> Transform(GeneratorAttributeSyntaxCo
return Result<GenerateBuilderModel>.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<GenerateBuilderModel>.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.
Expand Down Expand Up @@ -182,8 +185,20 @@ private static Result<GenerateBuilderModel> Transform(GeneratorAttributeSyntaxCo
ValidatePartialOrder(holder.Name, steps, diagnostics);

var assembleParameters = new List<BuilderAssembleParameterModel>();
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)
{
Expand Down Expand Up @@ -212,6 +227,7 @@ private static Result<GenerateBuilderModel> Transform(GeneratorAttributeSyntaxCo
holder.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
namespaceName,
assembleMethod.IsStatic,
assembleIsAsync,
assembleMethod.Name,
assembleMethod.ReturnType.ToDisplayString(TypeDisplayFormat),
new EquatableArray<BuilderStepModel>(steps.ToArray()),
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,27 +69,34 @@ public override int GetHashCode()

internal readonly struct BuilderAssembleParameterModel : IEquatable<BuilderAssembleParameterModel>
{
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);

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();
}
}
}
Expand All @@ -101,6 +108,7 @@ public GenerateBuilderModel(
string holderFullyQualifiedName,
string? namespaceName,
bool assembleIsStatic,
bool assembleIsAsync,
string assembleMethodName,
string productTypeDisplay,
EquatableArray<BuilderStepModel> steps,
Expand All @@ -111,6 +119,7 @@ public GenerateBuilderModel(
HolderFullyQualifiedName = holderFullyQualifiedName;
NamespaceName = namespaceName;
AssembleIsStatic = assembleIsStatic;
AssembleIsAsync = assembleIsAsync;
AssembleMethodName = assembleMethodName;
ProductTypeDisplay = productTypeDisplay;
Steps = steps;
Expand All @@ -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<BuilderStepModel> Steps { get; }
Expand All @@ -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)
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ private static void AppendExtensions(
string stateType,
string completeBuilderType)
{
var exitName = model.AssembleIsAsync ? "BuildAsync" : "Build";
sb.Append(indent).AppendLine("/// <summary>");
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("/// </summary>");
sb.Append(indent).AppendLine("[global::System.CodeDom.Compiler.GeneratedCode(\"DesignPatterns.SourceGenerators\", \"1.0\")]");
sb.Append(indent).AppendLine($"public static class {model.BuilderName}Extensions");
Expand Down Expand Up @@ -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(" /// <summary>");
sb.Append(indent).AppendLine($" /// Builds the product by invoking {model.HolderName}.{model.AssembleMethodName}.");
sb.Append(indent).AppendLine(" /// </summary>");
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<string>();
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
TestAssembly_CancellableAssembleSchema.Builder.g.cs:
// <auto-generated />
// Generated by DesignPatterns.SourceGenerators.GenerateBuilderGenerator
#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618
using System;
using System.Collections.Generic;

namespace TestAssembly
{
#nullable enable
/// <summary>
/// Fluent step builder entry point for CancellableAssembleSchema.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")]
public static class CancellableAssembleSchemaBuilder
{
/// <summary>
/// Creates a new builder with no required steps applied.
/// </summary>
public static CancellableAssembleSchemaBuilder<global::DesignPatterns.Creational.BuilderStepState.NotSet> Create() => new CancellableAssembleSchemaBuilder<global::DesignPatterns.Creational.BuilderStepState.NotSet>(new CancellableAssembleSchemaBuilderState());
}

/// <summary>
/// Typed step builder for CancellableAssembleSchema.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")]
public sealed class CancellableAssembleSchemaBuilder<TUrl>
{
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<string> AppliedOrder = new List<string>();
public CancellableAssembleSchemaBuilderState Clone()
{
var clone = (CancellableAssembleSchemaBuilderState)MemberwiseClone();
clone.AppliedOrder = new List<string>(AppliedOrder);
return clone;
}
}

/// <summary>
/// Step and BuildAsync extensions for CancellableAssembleSchemaBuilder.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")]
public static class CancellableAssembleSchemaBuilderExtensions
{
/// <summary>
/// Applies the required 'WithUrl' step.
/// </summary>
public static CancellableAssembleSchemaBuilder<global::DesignPatterns.Creational.BuilderStepState.Set> WithUrl(this CancellableAssembleSchemaBuilder<global::DesignPatterns.Creational.BuilderStepState.NotSet> builder, string url)
{
var state = builder.State.Clone();
state.Url = url;
state.UrlSet = true;
state.AppliedOrder.Add("WithUrl");
return new CancellableAssembleSchemaBuilder<global::DesignPatterns.Creational.BuilderStepState.Set>(state);
}

/// <summary>
/// Builds the product by invoking CancellableAssembleSchema.Assemble.
/// </summary>
public static global::System.Threading.Tasks.Task<string> BuildAsync(this CancellableAssembleSchemaBuilder<global::DesignPatterns.Creational.BuilderStepState.Set> builder, global::System.Threading.CancellationToken cancellationToken = default)
{
var state = builder.State;
return global::TestAssembly.CancellableAssembleSchema.Assemble(state.Url!, cancellationToken);
}
}
}
}
Loading
Loading