diff --git a/src/PatternKit.Core/Application/ActivityTracking/ActivityTracker.cs b/src/PatternKit.Core/Application/ActivityTracking/ActivityTracker.cs index 5073d3d5..58b117dd 100644 --- a/src/PatternKit.Core/Application/ActivityTracking/ActivityTracker.cs +++ b/src/PatternKit.Core/Application/ActivityTracking/ActivityTracker.cs @@ -63,7 +63,16 @@ public IReadOnlyList Snapshot() } public ActivityGateState GetGateState() - => new(Name, IsBlocked, ActiveCount, Snapshot()); + { + lock (_gate) + { + var activities = _activities.Values + .OrderBy(static activity => activity.StartedAt) + .ThenBy(static activity => activity.Id, StringComparer.Ordinal) + .ToArray(); + return new(Name, activities.Length > 0, activities.Length, activities); + } + } public static Builder Create(string name = "activity-tracker") => new(name); diff --git a/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs b/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs index 6f75e04a..bbb7ccbd 100644 --- a/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs +++ b/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs @@ -55,28 +55,53 @@ public MessageChannelReceiveResult TryReceive() } public IReadOnlyList> Drain(Func, bool>? predicate = null) + => DrainWithState(predicate).Removed; + + internal MessageChannelDrainResult DrainWithState(Func, bool>? predicate = null) { - var removed = new List>(); + Message[] snapshot; lock (_gate) - { - var messages = _messages.ToArray(); - var retained = new List>(messages.Length); + snapshot = _messages.ToArray(); + + var removed = predicate is null + ? snapshot + : snapshot.Where(predicate).ToArray(); + if (removed.Length == 0) + return new MessageChannelDrainResult([], Count); + + var removedCounts = removed + .GroupBy(static message => message) + .ToDictionary(static group => group.Key, static group => group.Count()); + var actualRemoved = new List>(removed.Length); + var retained = new List>(); - foreach (var message in messages) + lock (_gate) + { + while (_messages.Count > 0) { - if (predicate is null || predicate(message)) - removed.Add(message); + var message = _messages.Dequeue(); + if (removedCounts.TryGetValue(message, out var remainingRemovals)) + { + actualRemoved.Add(message); + if (remainingRemovals == 1) + removedCounts.Remove(message); + else + removedCounts[message] = remainingRemovals - 1; + } else + { retained.Add(message); + } } + var remainingCount = retained.Count; _messages.Clear(); foreach (var message in retained) _messages.Enqueue(message); - } - return removed; + return new MessageChannelDrainResult(actualRemoved, remainingCount); + } } public IReadOnlyList> Snapshot() @@ -300,11 +325,11 @@ private ChannelPurger( public ChannelPurgeResult Purge() { - var purged = _channel.Drain(_predicate); - foreach (var message in purged) + var result = _channel.DrainWithState(_predicate); + foreach (var message in result.Removed) _audit?.Invoke(new(Name, _channel.Name, message)); - return new(Name, _channel.Name, purged.Count, _channel.Count, purged); + return new(Name, _channel.Name, result.Removed.Count, result.RemainingCount, result.Removed); } public static Builder Create(string name = "channel-purger") => new(name); @@ -385,6 +410,16 @@ public ChannelPurgeResult( public IReadOnlyList> PurgedMessages { get; } } +internal sealed class MessageChannelDrainResult +{ + public MessageChannelDrainResult(IReadOnlyList> removed, int remainingCount) + => (Removed, RemainingCount) = (removed, remainingCount); + + public IReadOnlyList> Removed { get; } + + public int RemainingCount { get; } +} + public sealed class InvalidMessageChannel { private readonly MessageChannel> _invalidChannel; diff --git a/src/PatternKit.Core/Messaging/Consumers/DurableSubscriber.cs b/src/PatternKit.Core/Messaging/Consumers/DurableSubscriber.cs index 567fc5da..2c95def6 100644 --- a/src/PatternKit.Core/Messaging/Consumers/DurableSubscriber.cs +++ b/src/PatternKit.Core/Messaging/Consumers/DurableSubscriber.cs @@ -63,7 +63,7 @@ public DurableSubscriberResult CatchUp(MessageStoreQuery? query = null } } - if (messageFailed && _errorPolicy == DurableSubscriberErrorPolicy.StopOnFirstFailure) + if (messageFailed) break; if (!messageFailed) diff --git a/src/PatternKit.Core/Messaging/Routing/DynamicRouter.cs b/src/PatternKit.Core/Messaging/Routing/DynamicRouter.cs index 906621f5..0f8618ef 100644 --- a/src/PatternKit.Core/Messaging/Routing/DynamicRouter.cs +++ b/src/PatternKit.Core/Messaging/Routing/DynamicRouter.cs @@ -21,7 +21,7 @@ private DynamicRouter(RouteEntry[] routes, RouteHandler? @default) => (_routes, _default) = (routes, @default); /// Current ordered route names. - public IReadOnlyList RouteNames => _routes.Select(static route => route.Name).ToArray(); + public IReadOnlyList RouteNames => Volatile.Read(ref _routes).Select(static route => route.Name).ToArray(); /// Registers or replaces a route in the runtime route table. public DynamicRouter Register(string name, int order, RoutePredicate predicate, RouteHandler handler) @@ -38,12 +38,13 @@ public DynamicRouter Register(string name, int order, RoutePr var entry = new RouteEntry(name, order, predicate, handler); lock (_gate) { - _routes = _routes + var next = _routes .Where(route => !string.Equals(route.Name, name, StringComparison.Ordinal)) .Append(entry) .OrderBy(static route => route.Order) .ThenBy(static route => route.Name, StringComparer.Ordinal) .ToArray(); + Volatile.Write(ref _routes, next); } return this; @@ -57,11 +58,12 @@ public bool Unregister(string name) lock (_gate) { - var next = _routes.Where(route => !string.Equals(route.Name, name, StringComparison.Ordinal)).ToArray(); - if (next.Length == _routes.Length) + var snapshot = Volatile.Read(ref _routes); + var next = snapshot.Where(route => !string.Equals(route.Name, name, StringComparison.Ordinal)).ToArray(); + if (next.Length == snapshot.Length) return false; - _routes = next; + Volatile.Write(ref _routes, next); return true; } } @@ -73,7 +75,7 @@ public TResult Route(Message message, MessageContext? context = null) throw new ArgumentNullException(nameof(message)); var effectiveContext = context ?? MessageContext.From(message); - var snapshot = _routes; + var snapshot = Volatile.Read(ref _routes); foreach (var route in snapshot) if (route.Predicate(message, effectiveContext)) return route.Handler(message, effectiveContext); diff --git a/src/PatternKit.Generators/CacheAside/CacheAsidePolicyGenerator.cs b/src/PatternKit.Generators/CacheAside/CacheAsidePolicyGenerator.cs index f299e0fb..062478e5 100644 --- a/src/PatternKit.Generators/CacheAside/CacheAsidePolicyGenerator.cs +++ b/src/PatternKit.Generators/CacheAside/CacheAsidePolicyGenerator.cs @@ -130,31 +130,93 @@ private static string GenerateSource( sb.AppendLine(); } - sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + var indent = ""; + foreach (var containingType in GetContainingTypes(type)) + { + AppendTypeDeclaration(sb, containingType, indent); + sb.Append(indent).AppendLine("{"); + indent += " "; + } + + AppendTypeDeclaration(sb, type, indent); + sb.AppendLine("{"); + sb.Append(indent).Append(" public static global::PatternKit.Cloud.CacheAside.CacheAsidePolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).Append(" var builder = global::PatternKit.Cloud.CacheAside.CacheAsidePolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\");"); + + if (timeToLiveMilliseconds > 0) + sb.Append(indent).Append(" builder.WithTimeToLive(global::System.TimeSpan.FromMilliseconds(").Append(timeToLiveMilliseconds).AppendLine("));"); + else + sb.Append(indent).AppendLine(" builder.WithoutExpiration();"); + + if (predicate is not null) + sb.Append(indent).Append(" builder.CacheWhen(static value => ").Append(predicate.Name).AppendLine("(value));"); + + sb.Append(indent).AppendLine(" return builder.Build();"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine("}"); + + while (indent.Length > 0) + { + indent = indent.Substring(4); + sb.Append(indent).AppendLine("}"); + } + + return sb.ToString(); + } + + private static IReadOnlyList GetContainingTypes(INamedTypeSymbol type) + { + var stack = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + stack.Push(current); + return stack.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, string indent) + { + sb.Append(indent).Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); if (type.IsStatic) sb.Append("static "); else if (type.IsAbstract && type.TypeKind == TypeKind.Class) 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.Cloud.CacheAside.CacheAsidePolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" var builder = global::PatternKit.Cloud.CacheAside.CacheAsidePolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\");"); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ') + .Append(type.Name).Append(GetTypeParameterList(type)).Append(GetConstraintClauses(type)).AppendLine(); + } - if (timeToLiveMilliseconds > 0) - sb.Append(" builder.WithTimeToLive(global::System.TimeSpan.FromMilliseconds(").Append(timeToLiveMilliseconds).AppendLine("));"); - else - sb.AppendLine(" builder.WithoutExpiration();"); + private static string GetTypeParameterList(INamedTypeSymbol type) + => type.TypeParameters.Length == 0 + ? string.Empty + : "<" + string.Join(", ", type.TypeParameters.Select(static parameter => parameter.Name)) + ">"; - if (predicate is not null) - sb.Append(" builder.CacheWhen(static value => ").Append(predicate.Name).AppendLine("(value));"); + private static string GetConstraintClauses(INamedTypeSymbol type) + { + if (type.TypeParameters.Length == 0) + return string.Empty; - sb.AppendLine(" return builder.Build();"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); + var clauses = new List(); + foreach (var parameter in type.TypeParameters) + { + var constraints = new List(); + if (parameter.HasReferenceTypeConstraint) + constraints.Add(parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated ? "class?" : "class"); + if (parameter.HasNotNullConstraint) + constraints.Add("notnull"); + if (parameter.HasUnmanagedTypeConstraint) + constraints.Add("unmanaged"); + else if (parameter.HasValueTypeConstraint) + constraints.Add("struct"); + + constraints.AddRange(parameter.ConstraintTypes.Select(static constraint => constraint.ToDisplayString(TypeFormat))); + if (parameter.HasConstructorConstraint) + constraints.Add("new()"); + if (constraints.Count > 0) + clauses.Add($" where {parameter.Name} : {string.Join(", ", constraints)}"); + } + + return string.Concat(clauses); } private static bool IsCachePredicate(IMethodSymbol method, ITypeSymbol resultType) diff --git a/src/PatternKit.Generators/CircuitBreaker/CircuitBreakerPolicyGenerator.cs b/src/PatternKit.Generators/CircuitBreaker/CircuitBreakerPolicyGenerator.cs index bd1ef098..112b57c8 100644 --- a/src/PatternKit.Generators/CircuitBreaker/CircuitBreakerPolicyGenerator.cs +++ b/src/PatternKit.Generators/CircuitBreaker/CircuitBreakerPolicyGenerator.cs @@ -151,30 +151,92 @@ private static string GenerateSource( sb.AppendLine(); } - sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + var indent = ""; + foreach (var containingType in GetContainingTypes(type)) + { + AppendTypeDeclaration(sb, containingType, indent); + sb.Append(indent).AppendLine("{"); + indent += " "; + } + + AppendTypeDeclaration(sb, type, indent); + sb.AppendLine("{"); + sb.Append(indent).Append(" public static global::PatternKit.Cloud.CircuitBreaker.CircuitBreakerPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).Append(" var builder = global::PatternKit.Cloud.CircuitBreaker.CircuitBreakerPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); + sb.Append(indent).Append(" .WithFailureThreshold(").Append(failureThreshold).AppendLine(")"); + sb.Append(indent).Append(" .WithBreakDuration(global::System.TimeSpan.FromMilliseconds(").Append(breakDurationMilliseconds).AppendLine("));"); + + if (resultPredicate is not null) + sb.Append(indent).Append(" builder.HandleResult(static result => ").Append(resultPredicate.Name).AppendLine("(result));"); + if (exceptionPredicate is not null) + sb.Append(indent).Append(" builder.HandleException(static exception => ").Append(exceptionPredicate.Name).AppendLine("(exception));"); + + sb.Append(indent).AppendLine(" return builder.Build();"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine("}"); + + while (indent.Length > 0) + { + indent = indent.Substring(4); + sb.Append(indent).AppendLine("}"); + } + + return sb.ToString(); + } + + private static IReadOnlyList GetContainingTypes(INamedTypeSymbol type) + { + var stack = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + stack.Push(current); + return stack.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, string indent) + { + sb.Append(indent).Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); if (type.IsStatic) sb.Append("static "); else if (type.IsAbstract && type.TypeKind == TypeKind.Class) 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.Cloud.CircuitBreaker.CircuitBreakerPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" var builder = global::PatternKit.Cloud.CircuitBreaker.CircuitBreakerPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); - sb.Append(" .WithFailureThreshold(").Append(failureThreshold).AppendLine(")"); - sb.Append(" .WithBreakDuration(global::System.TimeSpan.FromMilliseconds(").Append(breakDurationMilliseconds).AppendLine("));"); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ') + .Append(type.Name).Append(GetTypeParameterList(type)).Append(GetConstraintClauses(type)).AppendLine(); + } - if (resultPredicate is not null) - sb.Append(" builder.HandleResult(static result => ").Append(resultPredicate.Name).AppendLine("(result));"); - if (exceptionPredicate is not null) - sb.Append(" builder.HandleException(static exception => ").Append(exceptionPredicate.Name).AppendLine("(exception));"); + private static string GetTypeParameterList(INamedTypeSymbol type) + => type.TypeParameters.Length == 0 + ? string.Empty + : "<" + string.Join(", ", type.TypeParameters.Select(static parameter => parameter.Name)) + ">"; - sb.AppendLine(" return builder.Build();"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); + private static string GetConstraintClauses(INamedTypeSymbol type) + { + if (type.TypeParameters.Length == 0) + return string.Empty; + + var clauses = new List(); + foreach (var parameter in type.TypeParameters) + { + var constraints = new List(); + if (parameter.HasReferenceTypeConstraint) + constraints.Add(parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated ? "class?" : "class"); + if (parameter.HasNotNullConstraint) + constraints.Add("notnull"); + if (parameter.HasUnmanagedTypeConstraint) + constraints.Add("unmanaged"); + else if (parameter.HasValueTypeConstraint) + constraints.Add("struct"); + + constraints.AddRange(parameter.ConstraintTypes.Select(static constraint => constraint.ToDisplayString(TypeFormat))); + if (parameter.HasConstructorConstraint) + constraints.Add("new()"); + if (constraints.Count > 0) + clauses.Add($" where {parameter.Name} : {string.Join(", ", constraints)}"); + } + + return string.Concat(clauses); } private static bool IsResultPredicate(IMethodSymbol method, ITypeSymbol resultType) diff --git a/src/PatternKit.Generators/Composite/CompositeGenerator.cs b/src/PatternKit.Generators/Composite/CompositeGenerator.cs index e6a971a2..f45ee87e 100644 --- a/src/PatternKit.Generators/Composite/CompositeGenerator.cs +++ b/src/PatternKit.Generators/Composite/CompositeGenerator.cs @@ -229,6 +229,7 @@ private static IEnumerable GetContractProperties(INamedTypeSymb component.GetMembers() .OfType() .Where(p => !p.IsStatic && !p.IsIndexer && p.GetMethod is not null && !HasIgnore(p)) + .Where(p => component.TypeKind != TypeKind.Class || p.IsAbstract || p.IsVirtual || (p.IsOverride && !p.IsSealed)) .OrderBy(p => p.Name, StringComparer.Ordinal); private static bool IsPartial(SyntaxNode node) => diff --git a/src/PatternKit.Generators/DecoratorGenerator.cs b/src/PatternKit.Generators/DecoratorGenerator.cs index 9cba58ca..8d7b1763 100644 --- a/src/PatternKit.Generators/DecoratorGenerator.cs +++ b/src/PatternKit.Generators/DecoratorGenerator.cs @@ -663,14 +663,38 @@ private static string FormatDefaultValue(IParameterSymbol param) var value = param.ExplicitDefaultValue; return value switch { - float f => f.ToString(System.Globalization.CultureInfo.InvariantCulture) + "f", - double d => d.ToString(System.Globalization.CultureInfo.InvariantCulture) + "d", + float f => FormatSingleDefault(f), + double d => FormatDoubleDefault(d), decimal m => m.ToString(System.Globalization.CultureInfo.InvariantCulture) + "m", _ => Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatPrimitive(value, quoteStrings: true, useHexadecimalNumbers: false) ?? "default" }; } + private static string FormatSingleDefault(float value) + { + if (float.IsNaN(value)) + return "float.NaN"; + if (float.IsPositiveInfinity(value)) + return "float.PositiveInfinity"; + if (float.IsNegativeInfinity(value)) + return "float.NegativeInfinity"; + + return value.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "f"; + } + + private static string FormatDoubleDefault(double value) + { + if (double.IsNaN(value)) + return "double.NaN"; + if (double.IsPositiveInfinity(value)) + return "double.PositiveInfinity"; + if (double.IsNegativeInfinity(value)) + return "double.NegativeInfinity"; + + return value.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "d"; + } + private static bool HasAttribute(ISymbol symbol, string attributeName) { return symbol.GetAttributes().Any(a => diff --git a/src/PatternKit.Generators/MementoGenerator.cs b/src/PatternKit.Generators/MementoGenerator.cs index a9a611be..df848e67 100644 --- a/src/PatternKit.Generators/MementoGenerator.cs +++ b/src/PatternKit.Generators/MementoGenerator.cs @@ -494,6 +494,7 @@ private static void AppendRecordFallbackConstructor(StringBuilder sb, TypeInfo t { var fallbackCtor = typeInfo.TypeSymbol.Constructors .Where(c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public) + .Where(c => !IsRecordCopyConstructor(c, typeInfo.TypeSymbol)) .OrderBy(c => c.Parameters.Length) .FirstOrDefault(); @@ -508,6 +509,10 @@ private static void AppendRecordFallbackConstructor(StringBuilder sb, TypeInfo t sb.AppendLine(")"); } + private static bool IsRecordCopyConstructor(IMethodSymbol constructor, INamedTypeSymbol type) + => constructor.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, type); + private void GenerateInPlaceRestoreMethod(StringBuilder sb, TypeInfo typeInfo) { sb.AppendLine($" /// Restores the memento state to an existing originator instance (in-place)."); diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index 63363b33..7905440e 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -531,9 +531,10 @@ private bool IsImmutableReferenceType(ITypeSymbol type) if (type.IsValueType) return true; - // Check for known immutable collections (basic check) - var typeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (typeName.Contains("System.Collections.Immutable.", StringComparison.Ordinal)) + // Check for known immutable collections. + var namespaceName = type.ContainingNamespace.ToDisplayString(); + if (namespaceName == "System.Collections.Immutable" || + namespaceName.StartsWith("System.Collections.Immutable.", StringComparison.Ordinal)) return true; // Conservative: assume mutable diff --git a/src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs b/src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs index 97dfcf9d..486acf76 100644 --- a/src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs +++ b/src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs @@ -153,31 +153,93 @@ private static string GenerateSource( sb.AppendLine(); } - sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + var indent = ""; + foreach (var containingType in GetContainingTypes(type)) + { + AppendTypeDeclaration(sb, containingType, indent); + sb.Append(indent).AppendLine("{"); + indent += " "; + } + + AppendTypeDeclaration(sb, type, indent); + sb.AppendLine("{"); + sb.Append(indent).Append(" public static global::PatternKit.Cloud.Retry.RetryPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).Append(" var builder = global::PatternKit.Cloud.Retry.RetryPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); + sb.Append(indent).Append(" .WithMaxAttempts(").Append(maxAttempts).AppendLine(")"); + sb.Append(indent).Append(" .WithInitialDelay(global::System.TimeSpan.FromMilliseconds(").Append(initialDelayMilliseconds).AppendLine("))"); + sb.Append(indent).Append(" .WithExponentialBackoff(").Append(backoffFactor.ToString(System.Globalization.CultureInfo.InvariantCulture)).AppendLine(");"); + + if (resultPredicate is not null) + sb.Append(indent).Append(" builder.HandleResult(static result => ").Append(resultPredicate.Name).AppendLine("(result));"); + if (exceptionPredicate is not null) + sb.Append(indent).Append(" builder.HandleException(static exception => ").Append(exceptionPredicate.Name).AppendLine("(exception));"); + + sb.Append(indent).AppendLine(" return builder.Build();"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine("}"); + + while (indent.Length > 0) + { + indent = indent.Substring(4); + sb.Append(indent).AppendLine("}"); + } + + return sb.ToString(); + } + + private static IReadOnlyList GetContainingTypes(INamedTypeSymbol type) + { + var stack = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + stack.Push(current); + return stack.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, string indent) + { + sb.Append(indent).Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); if (type.IsStatic) sb.Append("static "); else if (type.IsAbstract && type.TypeKind == TypeKind.Class) 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.Cloud.Retry.RetryPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); - sb.AppendLine(" {"); - sb.Append(" var builder = global::PatternKit.Cloud.Retry.RetryPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); - sb.Append(" .WithMaxAttempts(").Append(maxAttempts).AppendLine(")"); - sb.Append(" .WithInitialDelay(global::System.TimeSpan.FromMilliseconds(").Append(initialDelayMilliseconds).AppendLine("))"); - sb.Append(" .WithExponentialBackoff(").Append(backoffFactor.ToString(System.Globalization.CultureInfo.InvariantCulture)).AppendLine(");"); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ') + .Append(type.Name).Append(GetTypeParameterList(type)).Append(GetConstraintClauses(type)).AppendLine(); + } - if (resultPredicate is not null) - sb.Append(" builder.HandleResult(static result => ").Append(resultPredicate.Name).AppendLine("(result));"); - if (exceptionPredicate is not null) - sb.Append(" builder.HandleException(static exception => ").Append(exceptionPredicate.Name).AppendLine("(exception));"); + private static string GetTypeParameterList(INamedTypeSymbol type) + => type.TypeParameters.Length == 0 + ? string.Empty + : "<" + string.Join(", ", type.TypeParameters.Select(static parameter => parameter.Name)) + ">"; - sb.AppendLine(" return builder.Build();"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - return sb.ToString(); + private static string GetConstraintClauses(INamedTypeSymbol type) + { + if (type.TypeParameters.Length == 0) + return string.Empty; + + var clauses = new List(); + foreach (var parameter in type.TypeParameters) + { + var constraints = new List(); + if (parameter.HasReferenceTypeConstraint) + constraints.Add(parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated ? "class?" : "class"); + if (parameter.HasNotNullConstraint) + constraints.Add("notnull"); + if (parameter.HasUnmanagedTypeConstraint) + constraints.Add("unmanaged"); + else if (parameter.HasValueTypeConstraint) + constraints.Add("struct"); + + constraints.AddRange(parameter.ConstraintTypes.Select(static constraint => constraint.ToDisplayString(TypeFormat))); + if (parameter.HasConstructorConstraint) + constraints.Add("new()"); + if (constraints.Count > 0) + clauses.Add($" where {parameter.Name} : {string.Join(", ", constraints)}"); + } + + return string.Concat(clauses); } private static bool IsResultPredicate(IMethodSymbol method, ITypeSymbol resultType) diff --git a/test/PatternKit.Generators.Tests/CacheAsidePolicyGeneratorTests.cs b/test/PatternKit.Generators.Tests/CacheAsidePolicyGeneratorTests.cs index bcb94658..4a757505 100644 --- a/test/PatternKit.Generators.Tests/CacheAsidePolicyGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/CacheAsidePolicyGeneratorTests.cs @@ -190,7 +190,7 @@ public void GeneratesCacheAsidePolicySourceForNestedAccessibilityVariants() namespace Demo; - public partial class Outer + public partial class Outer where T : class, new() { [GenerateCacheAsidePolicy(typeof(string), FactoryMethodName = "CreatePrivate")] private partial class PrivateCacheAsideHost; @@ -208,14 +208,16 @@ private protected partial class PrivateProtectedCacheAsideHost; var comp = CreateCompilation(source, nameof(GeneratesCacheAsidePolicySourceForNestedAccessibilityVariants)); var gen = new CacheAsidePolicyGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); var generatedText = string.Join("\n", run.Results.SelectMany(result => result.GeneratedSources).Select(source => source.SourceText.ToString())); + ScenarioExpect.Contains("public partial class Outer where T : class, new()", generatedText); ScenarioExpect.Contains("private partial class PrivateCacheAsideHost", generatedText); ScenarioExpect.Contains("protected partial class ProtectedCacheAsideHost", generatedText); ScenarioExpect.Contains("protected internal partial class ProtectedInternalCacheAsideHost", generatedText); ScenarioExpect.Contains("private protected partial class PrivateProtectedCacheAsideHost", generatedText); + ScenarioExpect.True(updated.Emit(Stream.Null).Success, string.Join("\n", updated.GetDiagnostics())); } private static CSharpCompilation CreateCompilation(string source, string assemblyName) diff --git a/test/PatternKit.Generators.Tests/CircuitBreakerPolicyGeneratorTests.cs b/test/PatternKit.Generators.Tests/CircuitBreakerPolicyGeneratorTests.cs index d839729c..12668baa 100644 --- a/test/PatternKit.Generators.Tests/CircuitBreakerPolicyGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/CircuitBreakerPolicyGeneratorTests.cs @@ -245,7 +245,7 @@ public void GeneratesCircuitBreakerPolicySourceForNestedAccessibilityVariants() namespace Demo; - public partial class Outer + public partial class Outer where T : class, new() { [GenerateCircuitBreakerPolicy(typeof(string), FactoryMethodName = "CreatePrivate")] private partial class PrivateCircuitBreakerHost; @@ -263,14 +263,16 @@ private protected partial class PrivateProtectedCircuitBreakerHost; var comp = CreateCompilation(source, nameof(GeneratesCircuitBreakerPolicySourceForNestedAccessibilityVariants)); var gen = new CircuitBreakerPolicyGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); var generatedText = string.Join("\n", run.Results.SelectMany(result => result.GeneratedSources).Select(source => source.SourceText.ToString())); + ScenarioExpect.Contains("public partial class Outer where T : class, new()", generatedText); ScenarioExpect.Contains("private partial class PrivateCircuitBreakerHost", generatedText); ScenarioExpect.Contains("protected partial class ProtectedCircuitBreakerHost", generatedText); ScenarioExpect.Contains("protected internal partial class ProtectedInternalCircuitBreakerHost", generatedText); ScenarioExpect.Contains("private protected partial class PrivateProtectedCircuitBreakerHost", generatedText); + ScenarioExpect.True(updated.Emit(Stream.Null).Success, string.Join("\n", updated.GetDiagnostics())); } private static CSharpCompilation CreateCompilation(string source, string assemblyName) diff --git a/test/PatternKit.Generators.Tests/CompositeGeneratorTests.cs b/test/PatternKit.Generators.Tests/CompositeGeneratorTests.cs index cfd27235..f8203bd4 100644 --- a/test/PatternKit.Generators.Tests/CompositeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/CompositeGeneratorTests.cs @@ -92,6 +92,37 @@ public abstract partial class MenuItem ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + [Scenario("AbstractClassCompositeSkipsConcreteNonVirtualProperties")] + [Fact] + public void AbstractClassCompositeSkipsConcreteNonVirtualProperties() + { + const string source = """ + using PatternKit.Generators.Composite; + + namespace TestNamespace; + + [CompositeComponent] + public abstract partial class CatalogNode + { + public string Sku { get; } = ""; + + public abstract string Name { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(AbstractClassCompositeSkipsConcreteNonVirtualProperties)); + var gen = new CompositeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + var sourceText = ScenarioExpect.Single(result.Results.SelectMany(r => r.GeneratedSources)).SourceText.ToString(); + ScenarioExpect.Contains("public abstract override string Name { get; }", sourceText); + ScenarioExpect.DoesNotContain("Sku", sourceText); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + [Scenario("ReportsDiagnosticWhenComponentIsNotPartial")] [Fact] public void ReportsDiagnosticWhenComponentIsNotPartial() diff --git a/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs index 373d16d0..236eb622 100644 --- a/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs @@ -343,6 +343,39 @@ public interface IStorage ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + [Scenario("GenerateDecoratorForInterface PreservesFloatingPointDefaults")] + [Fact] + public void GenerateDecoratorForInterface_PreservesFloatingPointDefaults() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IScoring + { + double Normalize(double score = double.NaN, float weight = float.PositiveInfinity); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_PreservesFloatingPointDefaults)); + var gen = new DecoratorGenerator(); + _ = 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) + .First(gs => gs.HintName == "TestNamespace_IScoring.Decorator.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("double score = double.NaN", generatedSource); + ScenarioExpect.Contains("float weight = float.PositiveInfinity", generatedSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + [Scenario("GenerateDecoratorForInterface DeterministicOrdering")] [Fact] public void GenerateDecoratorForInterface_DeterministicOrdering() diff --git a/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs b/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs index 94faaa7a..63fadc17 100644 --- a/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs @@ -217,6 +217,36 @@ public partial class Document ScenarioExpect.DoesNotContain("InternalId", mementoSource); } + [Scenario("RecordFallbackConstructorExcludesCopyConstructor")] + [Fact] + public void RecordFallbackConstructorExcludesCopyConstructor() + { + const string source = """ + using PatternKit.Generators; + + namespace TestNamespace; + + [Memento] + public sealed partial record class EditorState(string Text, [property: MementoIgnore] string Secret); + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordFallbackConstructorExcludesCopyConstructor)); + var gen = new MementoGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.DoesNotContain(r.Diagnostics, d => d.Severity == DiagnosticSeverity.Error)); + var mementoSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Memento.g.cs")) + .SourceText.ToString(); + + ScenarioExpect.Contains("new global::TestNamespace.EditorState(default!, default!)", mementoSource); + ScenarioExpect.DoesNotContain("new global::TestNamespace.EditorState(default!)", mementoSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + [Scenario("ExplicitInclusionMode")] [Fact] public void ExplicitInclusionMode() diff --git a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs index 10ababc7..f7bcbf5b 100644 --- a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs @@ -1181,6 +1181,79 @@ public sealed class ImmutableTags ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + [Scenario("MutableCollectionWithImmutableTypeArgumentWarnsInShallowWithWarningsMode")] + [Fact] + public void MutableCollectionWithImmutableTypeArgumentWarnsInShallowWithWarningsMode() + { + const string source = """ + using System.Collections.Generic; + using PatternKit.Generators.Prototype; + + namespace TestNamespace + { + [Prototype(Mode = PrototypeMode.ShallowWithWarnings)] + public partial class MutableSnapshot + { + public Dictionary Values { get; set; } = new(); + } + } + + namespace System.Collections.Immutable + { + public sealed class ImmutableTags + { + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(MutableCollectionWithImmutableTypeArgumentWarnsInShallowWithWarningsMode)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKPRO003"); + ScenarioExpect.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("ImmutableCollectionNamespacePrefixDoesNotSuppressMutableWarnings")] + [Fact] + public void ImmutableCollectionNamespacePrefixDoesNotSuppressMutableWarnings() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace + { + [Prototype(Mode = PrototypeMode.ShallowWithWarnings)] + public partial class MutableSnapshot + { + public System.Collections.ImmutableExtras.MutableTags Tags { get; set; } = new(); + } + } + + namespace System.Collections.ImmutableExtras + { + public sealed class MutableTags + { + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ImmutableCollectionNamespacePrefixDoesNotSuppressMutableWarnings)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKPRO003"); + ScenarioExpect.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + [Scenario("PrototypeIncludesFieldsAndShallowCopyReferenceFallback")] [Fact] public void PrototypeIncludesFieldsAndShallowCopyReferenceFallback() diff --git a/test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs b/test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs index bcb87951..be3ed55b 100644 --- a/test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs @@ -246,7 +246,7 @@ public void GeneratesRetryPolicySourceForNestedAccessibilityVariants() namespace Demo; - public partial class Outer + public partial class Outer where T : class, new() { [GenerateRetryPolicy(typeof(string), FactoryMethodName = "CreatePrivate")] private partial class PrivateRetryHost; @@ -264,14 +264,16 @@ private protected partial class PrivateProtectedRetryHost; var comp = CreateCompilation(source, nameof(GeneratesRetryPolicySourceForNestedAccessibilityVariants)); var gen = new RetryPolicyGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); var generatedText = string.Join("\n", run.Results.SelectMany(result => result.GeneratedSources).Select(source => source.SourceText.ToString())); + ScenarioExpect.Contains("public partial class Outer where T : class, new()", generatedText); ScenarioExpect.Contains("private partial class PrivateRetryHost", generatedText); ScenarioExpect.Contains("protected partial class ProtectedRetryHost", generatedText); ScenarioExpect.Contains("protected internal partial class ProtectedInternalRetryHost", generatedText); ScenarioExpect.Contains("private protected partial class PrivateProtectedRetryHost", generatedText); + ScenarioExpect.True(updated.Emit(Stream.Null).Success, string.Join("\n", updated.GetDiagnostics())); } private static CSharpCompilation CreateCompilation(string source, string assemblyName) diff --git a/test/PatternKit.Generators.Tests/SagaGeneratorTests.cs b/test/PatternKit.Generators.Tests/SagaGeneratorTests.cs index bd630b7f..ab5aa932 100644 --- a/test/PatternKit.Generators.Tests/SagaGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/SagaGeneratorTests.cs @@ -258,6 +258,8 @@ public static partial class OrderSaga var diagnostics = run.Results.SelectMany(result => result.Diagnostics).ToArray(); ScenarioExpect.Equal(2, diagnostics.Count(diagnostic => diagnostic.Id == "PKSG003")); + ScenarioExpect.Equal(2, diagnostics.Length); + ScenarioExpect.All(diagnostics, diagnostic => ScenarioExpect.Equal("PKSG003", diagnostic.Id)); } [Scenario("ReportsDiagnosticForInvalidCompletionSignature")] diff --git a/test/PatternKit.Tests/Messaging/Channels/MessageChannelTests.cs b/test/PatternKit.Tests/Messaging/Channels/MessageChannelTests.cs index c8895738..624313d3 100644 --- a/test/PatternKit.Tests/Messaging/Channels/MessageChannelTests.cs +++ b/test/PatternKit.Tests/Messaging/Channels/MessageChannelTests.cs @@ -55,6 +55,43 @@ public void BoundedChannel_DropsOldestWhenConfigured() ScenarioExpect.Equal("sku-2", channel.TryReceive().Message!.Payload.Sku); } + [Scenario("Drain EvaluatesPredicateOutsideChannelLock")] + [Fact] + public void Drain_EvaluatesPredicateOutsideChannelLock() + { + var channel = MessageChannel.Create("inventory").Build(); + channel.Send(Message.Create(new("sku-1", 3))); + channel.Send(Message.Create(new("sku-2", 5))); + + var drained = channel.Drain(message => + { + var snapshot = channel.Snapshot(); + return message.Payload.Sku == "sku-1" && snapshot.Count == 2; + }); + + ScenarioExpect.Equal("sku-1", ScenarioExpect.Single(drained).Payload.Sku); + ScenarioExpect.Equal("sku-2", ScenarioExpect.Single(channel.Snapshot()).Payload.Sku); + } + + [Scenario("ChannelPurger ReportsRemainingCountFromDrain")] + [Fact] + public void ChannelPurger_ReportsRemainingCountFromDrain() + { + var channel = MessageChannel.Create("inventory").Build(); + channel.Send(Message.Create(new("sku-1", 3))); + channel.Send(Message.Create(new("sku-2", 5))); + var purger = ChannelPurger.Create("expired") + .From(channel) + .When(static message => message.Payload.Sku == "sku-1") + .Build(); + + var result = purger.Purge(); + + ScenarioExpect.Equal(1, result.PurgedCount); + ScenarioExpect.Equal(1, result.RemainingCount); + ScenarioExpect.Equal("sku-1", ScenarioExpect.Single(result.PurgedMessages).Payload.Sku); + } + [Scenario("Builder RejectsInvalidConfiguration")] [Fact] public void Builder_RejectsInvalidConfiguration() diff --git a/test/PatternKit.Tests/Messaging/Consumers/DurableSubscriberTests.cs b/test/PatternKit.Tests/Messaging/Consumers/DurableSubscriberTests.cs index 4e573f9c..ae6508e7 100644 --- a/test/PatternKit.Tests/Messaging/Consumers/DurableSubscriberTests.cs +++ b/test/PatternKit.Tests/Messaging/Consumers/DurableSubscriberTests.cs @@ -59,6 +59,35 @@ public void CatchUp_StopsBeforeCheckpointingFailedMessages() ScenarioExpect.Equal(1L, checkpoints.Load("shipping").LastSequence); } + [Scenario("CatchUp ContinuePolicyDoesNotCheckpointPastFailedMessage")] + [Fact] + public void CatchUp_ContinuePolicyDoesNotCheckpointPastFailedMessage() + { + var store = CreateStore(); + var checkpoints = new InMemoryDurableSubscriberCheckpointStore(); + var handled = new List(); + _ = store.Append(Message.Create(new("order-1")).WithMessageId("m1")); + _ = store.Append(Message.Create(new("order-2")).WithMessageId("m2")); + var subscriber = DurableSubscriber.Create("shipping") + .From(store) + .TrackWith(checkpoints) + .Handle("reject", (stored, _) => stored.Message.Payload.Id == "order-1" + ? DurableSubscriberHandlerResult.Failure("reject", "projection unavailable") + : DurableSubscriberHandlerResult.Success("reject")) + .Handle("audit", (stored, _) => handled.Add(stored.Message.Payload.Id)) + .OnError(DurableSubscriberErrorPolicy.Continue) + .Build(); + + var result = subscriber.CatchUp(); + + ScenarioExpect.False(result.Completed); + ScenarioExpect.Equal(0, result.DeliveredCount); + ScenarioExpect.Equal(0L, result.LastSequence); + ScenarioExpect.Equal(0L, checkpoints.Load("shipping").LastSequence); + ScenarioExpect.Equal(["order-1"], handled); + ScenarioExpect.Single(result.Failures); + } + [Scenario("BuilderRejectsInvalidDurableSubscriberConfiguration")] [Fact] public void Builder_RejectsInvalidDurableSubscriberConfiguration()