From 57fdcd270cc7512accf7cd2efbac7737f45beb9d Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 11 May 2026 15:45:31 +0100 Subject: [PATCH 01/20] feat: add Brighter.SourceGenerators for compile-time handler/mapper/transform registration Adds a Roslyn incremental source generator that emits a partial method body registering handlers, message mappers and transforms discovered in the current compilation, replacing runtime AutoFromAssemblies reflection scanning. Includes: - [BrighterRegistrations] marker attribute on a partial static method - [ExcludeFromBrighterRegistration] opt-out attribute - IBrighterBuilder.Transforms callback for explicit transform registration - HelloWorld sample wired up via AddFromThisAssembly() Co-Authored-By: Claude Opus 4.7 --- Brighter.slnx | 1 + .../HelloWorld/BrighterRegistrations.cs | 10 + .../HelloWorld/HelloWorld.csproj | 5 +- .../HelloWorld/NoOpTransformer.cs | 22 + .../CommandProcessor/HelloWorld/Program.cs | 2 +- .../IBrighterBuilder.cs | 10 + .../ServiceCollectionBrighterBuilder.cs | 15 + .../BrighterRegistrationsGenerator.cs | 482 ++++++++++++++++++ .../Paramore.Brighter.SourceGenerators.csproj | 24 + 9 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs create mode 100644 samples/CommandProcessor/HelloWorld/NoOpTransformer.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj diff --git a/Brighter.slnx b/Brighter.slnx index e9cd6d5fa0..f1d3c0a107 100644 --- a/Brighter.slnx +++ b/Brighter.slnx @@ -229,6 +229,7 @@ + diff --git a/samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs b/samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs new file mode 100644 index 0000000000..664616e7b3 --- /dev/null +++ b/samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs @@ -0,0 +1,10 @@ +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +namespace HelloWorld; + +public static partial class BrighterRegistrations +{ + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); +} diff --git a/samples/CommandProcessor/HelloWorld/HelloWorld.csproj b/samples/CommandProcessor/HelloWorld/HelloWorld.csproj index 62dce7f381..8ff1554fcf 100644 --- a/samples/CommandProcessor/HelloWorld/HelloWorld.csproj +++ b/samples/CommandProcessor/HelloWorld/HelloWorld.csproj @@ -12,5 +12,8 @@ + - \ No newline at end of file + diff --git a/samples/CommandProcessor/HelloWorld/NoOpTransformer.cs b/samples/CommandProcessor/HelloWorld/NoOpTransformer.cs new file mode 100644 index 0000000000..26d8fbfdd3 --- /dev/null +++ b/samples/CommandProcessor/HelloWorld/NoOpTransformer.cs @@ -0,0 +1,22 @@ +#nullable enable +using Paramore.Brighter; + +namespace HelloWorld; + +/// +/// A do-nothing transform purely to exercise the source generator's transform discovery. +/// +public sealed class NoOpTransformer : IAmAMessageTransform +{ + public IRequestContext? Context { get; set; } + + public void InitializeWrapFromAttributeParams(params object?[] initializerList) { } + + public void InitializeUnwrapFromAttributeParams(params object?[] initializerList) { } + + public Message Wrap(Message message, Publication publication) => message; + + public Message Unwrap(Message message) => message; + + public void Dispose() { } +} diff --git a/samples/CommandProcessor/HelloWorld/Program.cs b/samples/CommandProcessor/HelloWorld/Program.cs index f81b6bb7db..30158ec6b3 100644 --- a/samples/CommandProcessor/HelloWorld/Program.cs +++ b/samples/CommandProcessor/HelloWorld/Program.cs @@ -30,7 +30,7 @@ THE SOFTWARE. */ using Paramore.Brighter.Extensions.DependencyInjection; var builder = Host.CreateApplicationBuilder(); -builder.Services.AddBrighter().AutoFromAssemblies(); +builder.Services.AddBrighter().AddFromThisAssembly(); var host = builder.Build(); var commandProcessor = host.Services.GetRequiredService(); diff --git a/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs b/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs index 5e37519231..1120df07c3 100644 --- a/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs +++ b/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs @@ -100,6 +100,16 @@ public interface IBrighterBuilder /// This builder, allows chaining calls IBrighterBuilder TransformsFromAssemblies(IEnumerable assemblies); + /// + /// Register transforms with the built-in transformer registry. Symmetric with + /// and + /// , intended for callers that want to add transforms + /// explicitly (for example from a source generator) rather than via assembly scanning. + /// + /// A callback to register transforms + /// This builder, allows chaining calls + IBrighterBuilder Transforms(Action registerTransforms); + /// /// [Obsolete] Gets or sets the legacy policy registry used for the command processor and event bus. /// diff --git a/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionBrighterBuilder.cs b/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionBrighterBuilder.cs index 348011e70c..b90dcbcb54 100644 --- a/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionBrighterBuilder.cs +++ b/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionBrighterBuilder.cs @@ -235,6 +235,21 @@ where typeof(IAmAMessageTransformAsync).IsAssignableFrom(i) || typeof(IAmAMessag return this; } + + /// + /// Register transforms with the built-in transformer registry. + /// + /// A callback to register transforms + /// This builder, allows chaining calls + public IBrighterBuilder Transforms(Action registerTransforms) + { + if (registerTransforms == null) + throw new ArgumentNullException(nameof(registerTransforms)); + + registerTransforms(_transformerRegistry); + + return this; + } private void RegisterHandlersFromAssembly(Type interfaceType, IEnumerable assemblies, Assembly assembly, IEnumerable? excludeDynamicHandlerTypes) { diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs new file mode 100644 index 0000000000..d915677bb6 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -0,0 +1,482 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Paramore.Brighter.SourceGenerators; + +/// +/// Emits a partial implementation of a user-declared method that registers Brighter handlers +/// and message mappers from the current compilation. The goal is to replace runtime +/// AutoFromAssemblies reflection scanning with compile-time generated registrations. +/// +[Generator(LanguageNames.CSharp)] +public sealed class BrighterRegistrationsGenerator : IIncrementalGenerator +{ + private const string AttributeName = "Paramore.Brighter.BrighterRegistrationsAttribute"; + private const string ExcludeAttributeName = "Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"; + + private const string AttributeSource = """ + // + #nullable enable + namespace Paramore.Brighter + { + /// + /// Marks a partial method that the Brighter source generator will implement + /// to register handlers and message mappers discovered in the current compilation. + /// The method must be static partial, return , + /// and take a single parameter (extension methods supported). + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + internal sealed class BrighterRegistrationsAttribute : global::System.Attribute + { + } + + /// + /// Excludes a handler / mapper / transform type from automatic Brighter registration. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + internal sealed class ExcludeFromBrighterRegistrationAttribute : global::System.Attribute + { + } + } + """; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(ctx => + ctx.AddSource("BrighterRegistrationsAttributes.g.cs", SourceText.From(AttributeSource, Encoding.UTF8))); + + var methodTargets = context.SyntaxProvider + .ForAttributeWithMetadataName( + AttributeName, + predicate: static (node, _) => node is MethodDeclarationSyntax, + transform: static (ctx, _) => (IMethodSymbol)ctx.TargetSymbol) + .Where(static m => m is not null) + .Collect(); + + var combined = methodTargets.Combine(context.CompilationProvider); + + context.RegisterSourceOutput(combined, static (spc, pair) => + { + var methods = pair.Left; + var compilation = pair.Right; + + if (methods.IsDefaultOrEmpty) + return; + + var symbols = MarkerSymbols.Resolve(compilation); + if (!symbols.IsValid) + { + // Brighter not in this compilation's reference graph — nothing to do. + return; + } + + var discovered = DiscoverRegistrations(compilation, symbols); + + foreach (var method in methods) + { + if (!ValidateMethod(method, symbols, spc, out var diagnostic)) + { + spc.ReportDiagnostic(diagnostic!); + continue; + } + + var source = EmitPartial(method, discovered); + var hint = $"{method.ContainingType.ToDisplayString().Replace('.', '_')}__{method.Name}.g.cs"; + spc.AddSource(hint, SourceText.From(source, Encoding.UTF8)); + } + }); + } + + private static bool ValidateMethod( + IMethodSymbol method, + MarkerSymbols symbols, + SourceProductionContext spc, + out Diagnostic? diagnostic) + { + diagnostic = null; + + if (!method.IsPartialDefinition) + { + diagnostic = Diagnostic.Create(Diagnostics.MustBePartial, method.Locations.FirstOrDefault(), method.Name); + return false; + } + + if (!method.IsStatic) + { + diagnostic = Diagnostic.Create(Diagnostics.MustBeStatic, method.Locations.FirstOrDefault(), method.Name); + return false; + } + + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, symbols.BrighterBuilder)) + { + diagnostic = Diagnostic.Create(Diagnostics.WrongReturnType, method.Locations.FirstOrDefault(), method.Name); + return false; + } + + if (method.Parameters.Length != 1 || + !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, symbols.BrighterBuilder)) + { + diagnostic = Diagnostic.Create(Diagnostics.WrongSignature, method.Locations.FirstOrDefault(), method.Name); + return false; + } + + return true; + } + + private static Registrations DiscoverRegistrations(Compilation compilation, MarkerSymbols symbols) + { + var result = new Registrations(); + var excludeAttr = compilation.GetTypeByMetadataName(ExcludeAttributeName); + + foreach (var type in EnumerateNamedTypes(compilation.SourceModule.GlobalNamespace)) + { + if (type.TypeKind != TypeKind.Class) + continue; + if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) + continue; + if (!IsReachableFromGeneratedCode(type)) + continue; + if (excludeAttr is not null && type.GetAttributes().Any(a => + SymbolEqualityComparer.Default.Equals(a.AttributeClass, excludeAttr))) + continue; + + var isTransform = false; + foreach (var iface in type.AllInterfaces) + { + if (iface.IsGenericType && iface.TypeArguments.Length == 1) + { + var def = iface.OriginalDefinition; + var requestType = iface.TypeArguments[0]; + + if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequests)) + result.Handlers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequestsAsync)) + result.AsyncHandlers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapper) && !type.IsGenericType) + result.Mappers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapperAsync) && !type.IsGenericType) + result.AsyncMappers.Add((requestType, type)); + } + else if (SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransform) || + SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransformAsync)) + { + isTransform = true; + } + } + + if (isTransform && !type.IsGenericType) + result.Transforms.Add(type); + } + + return result; + } + + private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) + { + for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) + { + if (t.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) + return false; + } + return true; + } + + private static IEnumerable EnumerateNamedTypes(INamespaceSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + switch (member) + { + case INamespaceSymbol child: + foreach (var t in EnumerateNamedTypes(child)) + yield return t; + break; + case INamedTypeSymbol type: + yield return type; + foreach (var nested in EnumerateNested(type)) + yield return nested; + break; + } + } + } + + private static IEnumerable EnumerateNested(INamedTypeSymbol type) + { + foreach (var nested in type.GetTypeMembers()) + { + yield return nested; + foreach (var n in EnumerateNested(nested)) + yield return n; + } + } + + private static string EmitPartial(IMethodSymbol method, Registrations registrations) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var containingType = method.ContainingType; + var ns = containingType.ContainingNamespace; + var hasNamespace = ns is { IsGlobalNamespace: false }; + + if (hasNamespace) + { + sb.Append("namespace ").Append(ns!.ToDisplayString()).AppendLine(); + sb.AppendLine("{"); + } + + var typeKeyword = containingType.IsStatic ? "static partial class" : "partial class"; + sb.Append(" ").Append(AccessibilityModifier(containingType)).Append(' ').Append(typeKeyword).Append(' ').AppendLine(containingType.Name); + sb.AppendLine(" {"); + + sb.Append(" ").Append(AccessibilityModifier(method)).Append(" static partial "); + sb.Append(FullyQualified(method.ReturnType)).Append(' ').Append(method.Name).Append('('); + if (method.IsExtensionMethod) + sb.Append("this "); + sb.Append(FullyQualified(method.Parameters[0].Type)).Append(' ').Append(method.Parameters[0].Name); + sb.AppendLine(")"); + sb.AppendLine(" {"); + + EmitHandlers(sb, method.Parameters[0].Name, registrations.Handlers, isAsync: false); + EmitHandlers(sb, method.Parameters[0].Name, registrations.AsyncHandlers, isAsync: true); + EmitMappers(sb, method.Parameters[0].Name, registrations.Mappers, registrations.AsyncMappers); + EmitTransforms(sb, method.Parameters[0].Name, registrations.Transforms); + + sb.Append(" return ").Append(method.Parameters[0].Name).AppendLine(";"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + + if (hasNamespace) + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static void EmitHandlers( + StringBuilder sb, + string paramName, + List<(ITypeSymbol Request, INamedTypeSymbol Handler)> entries, + bool isAsync) + { + if (entries.Count == 0) + return; + + var callbackMethod = isAsync ? "AsyncHandlers" : "Handlers"; + sb.Append(" ").Append(paramName).Append('.').Append(callbackMethod).AppendLine("(r =>"); + sb.AppendLine(" {"); + sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); + + foreach (var (request, handler) in entries) + { + if (handler.IsUnboundGenericType || handler.IsGenericType && handler.TypeParameters.Length > 0 && handler.IsDefinition) + { + sb.Append(" registry.EnsureHandlerIsRegistered(typeof(") + .Append(UnboundGenericName(handler)) + .AppendLine("));"); + } + else + { + sb.Append(" registry.Add(typeof(") + .Append(FullyQualified(request)) + .Append("), typeof(") + .Append(FullyQualified(handler)) + .AppendLine("));"); + } + } + + sb.AppendLine(" });"); + } + + private static void EmitMappers( + StringBuilder sb, + string paramName, + List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> sync, + List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> async) + { + if (sync.Count == 0 && async.Count == 0) + return; + + sb.Append(" ").Append(paramName).AppendLine(".MapperRegistry(r =>"); + sb.AppendLine(" {"); + + foreach (var (request, mapper) in sync) + { + sb.Append(" r.Add(typeof(") + .Append(FullyQualified(request)) + .Append("), typeof(") + .Append(FullyQualified(mapper)) + .AppendLine("));"); + } + + foreach (var (request, mapper) in async) + { + sb.Append(" r.AddAsync(typeof(") + .Append(FullyQualified(request)) + .Append("), typeof(") + .Append(FullyQualified(mapper)) + .AppendLine("));"); + } + + sb.AppendLine(" });"); + } + + private static void EmitTransforms( + StringBuilder sb, + string paramName, + List transforms) + { + if (transforms.Count == 0) + return; + + sb.Append(" ").Append(paramName).AppendLine(".Transforms(r =>"); + sb.AppendLine(" {"); + + foreach (var transform in transforms) + { + sb.Append(" r.Add(typeof(") + .Append(FullyQualified(transform)) + .AppendLine("));"); + } + + sb.AppendLine(" });"); + } + + private static string FullyQualified(ITypeSymbol type) => + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + private static string UnboundGenericName(INamedTypeSymbol type) + { + // Emit typeof(Foo<>) syntax for open generics. FullyQualifiedFormat produces Foo; + // we need Foo<,,>-style instead. + var name = FullyQualified(type); + var lt = name.IndexOf('<'); + if (lt < 0) + return name; + var arity = type.TypeParameters.Length; + return name.Substring(0, lt + 1) + new string(',', arity - 1) + ">"; + } + + private static string AccessibilityModifier(ISymbol symbol) => symbol.DeclaredAccessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "internal" + }; + + private sealed class MarkerSymbols + { + public INamedTypeSymbol? BrighterBuilder { get; } + public INamedTypeSymbol? HandleRequests { get; } + public INamedTypeSymbol? HandleRequestsAsync { get; } + public INamedTypeSymbol? MessageMapper { get; } + public INamedTypeSymbol? MessageMapperAsync { get; } + public INamedTypeSymbol? MessageTransform { get; } + public INamedTypeSymbol? MessageTransformAsync { get; } + + private MarkerSymbols( + INamedTypeSymbol? brighterBuilder, + INamedTypeSymbol? handleRequests, + INamedTypeSymbol? handleRequestsAsync, + INamedTypeSymbol? messageMapper, + INamedTypeSymbol? messageMapperAsync, + INamedTypeSymbol? messageTransform, + INamedTypeSymbol? messageTransformAsync) + { + BrighterBuilder = brighterBuilder; + HandleRequests = handleRequests; + HandleRequestsAsync = handleRequestsAsync; + MessageMapper = messageMapper; + MessageMapperAsync = messageMapperAsync; + MessageTransform = messageTransform; + MessageTransformAsync = messageTransformAsync; + } + + public bool IsValid => + BrighterBuilder is not null && + HandleRequests is not null && + HandleRequestsAsync is not null && + MessageMapper is not null && + MessageMapperAsync is not null && + MessageTransform is not null && + MessageTransformAsync is not null; + + public static MarkerSymbols Resolve(Compilation c) => new( + c.GetTypeByMetadataName("Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder"), + c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequests`1"), + c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequestsAsync`1"), + c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapper`1"), + c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapperAsync`1"), + c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransform"), + c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransformAsync")); + } + + private sealed class Registrations + { + public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> Handlers { get; } = new(); + public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> AsyncHandlers { get; } = new(); + public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> Mappers { get; } = new(); + public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> AsyncMappers { get; } = new(); + public List Transforms { get; } = new(); + } + + private static class Diagnostics + { + public static readonly DiagnosticDescriptor MustBePartial = new( + "BRGEN001", + "Brighter registration method must be partial", + "Method '{0}' marked with [BrighterRegistrations] must be a partial method", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MustBeStatic = new( + "BRGEN002", + "Brighter registration method must be static", + "Method '{0}' marked with [BrighterRegistrations] must be static", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor WrongReturnType = new( + "BRGEN003", + "Brighter registration method has wrong return type", + "Method '{0}' must return IBrighterBuilder", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor WrongSignature = new( + "BRGEN004", + "Brighter registration method has wrong signature", + "Method '{0}' must accept a single IBrighterBuilder parameter", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + } +} diff --git a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj new file mode 100644 index 0000000000..f471c830f1 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + Source generators for Brighter — emits handler/mapper/transform registrations at compile time so consumers can avoid AutoFromAssemblies reflection scanning. + SourceGenerator;Brighter;Paramore.Brighter + false + true + true + true + false + enable + + false + false + + $(NoWarn);RS2008 + + + + + + + From 30a39ffe3182079ee56af1555b35a25cd8033b8a Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 11 May 2026 15:53:11 +0100 Subject: [PATCH 02/20] refactor: reduce complexity in source generator per CodeScene feedback - Extract IsRegistrationCandidate / ClassifyType / TryClassifyGenericInterface / IsTransformInterface from DiscoverRegistrations - Extract IsOpenGeneric helper from EmitHandlers conditional - Replace 7-arg MarkerSymbols constructor with object initializer in Resolve Co-Authored-By: Claude Opus 4.7 --- .../BrighterRegistrationsGenerator.cs | 150 ++++++++++-------- 1 file changed, 82 insertions(+), 68 deletions(-) diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index d915677bb6..92e180a938 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -158,47 +158,74 @@ private static Registrations DiscoverRegistrations(Compilation compilation, Mark foreach (var type in EnumerateNamedTypes(compilation.SourceModule.GlobalNamespace)) { - if (type.TypeKind != TypeKind.Class) + if (!IsRegistrationCandidate(type, excludeAttr)) continue; - if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) - continue; - if (!IsReachableFromGeneratedCode(type)) - continue; - if (excludeAttr is not null && type.GetAttributes().Any(a => - SymbolEqualityComparer.Default.Equals(a.AttributeClass, excludeAttr))) - continue; - - var isTransform = false; - foreach (var iface in type.AllInterfaces) - { - if (iface.IsGenericType && iface.TypeArguments.Length == 1) - { - var def = iface.OriginalDefinition; - var requestType = iface.TypeArguments[0]; - - if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequests)) - result.Handlers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequestsAsync)) - result.AsyncHandlers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapper) && !type.IsGenericType) - result.Mappers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapperAsync) && !type.IsGenericType) - result.AsyncMappers.Add((requestType, type)); - } - else if (SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransform) || - SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransformAsync)) - { - isTransform = true; - } - } - if (isTransform && !type.IsGenericType) - result.Transforms.Add(type); + ClassifyType(type, symbols, result); } return result; } + private static bool IsRegistrationCandidate(INamedTypeSymbol type, INamedTypeSymbol? excludeAttr) + { + if (type.TypeKind != TypeKind.Class) + return false; + if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) + return false; + if (!IsReachableFromGeneratedCode(type)) + return false; + if (excludeAttr is not null && HasAttribute(type, excludeAttr)) + return false; + return true; + } + + private static bool HasAttribute(INamedTypeSymbol type, INamedTypeSymbol attribute) => + type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attribute)); + + private static void ClassifyType(INamedTypeSymbol type, MarkerSymbols symbols, Registrations result) + { + var isTransform = false; + foreach (var iface in type.AllInterfaces) + { + if (TryClassifyGenericInterface(type, iface, symbols, result)) + continue; + if (IsTransformInterface(iface, symbols)) + isTransform = true; + } + + if (isTransform && !type.IsGenericType) + result.Transforms.Add(type); + } + + private static bool TryClassifyGenericInterface( + INamedTypeSymbol type, + INamedTypeSymbol iface, + MarkerSymbols symbols, + Registrations result) + { + if (!iface.IsGenericType || iface.TypeArguments.Length != 1) + return false; + + var def = iface.OriginalDefinition; + var requestType = iface.TypeArguments[0]; + + if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequests)) + result.Handlers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequestsAsync)) + result.AsyncHandlers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapper) && !type.IsGenericType) + result.Mappers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapperAsync) && !type.IsGenericType) + result.AsyncMappers.Add((requestType, type)); + + return true; + } + + private static bool IsTransformInterface(INamedTypeSymbol iface, MarkerSymbols symbols) => + SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransform) || + SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransformAsync); + private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) { for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) @@ -298,7 +325,7 @@ private static void EmitHandlers( foreach (var (request, handler) in entries) { - if (handler.IsUnboundGenericType || handler.IsGenericType && handler.TypeParameters.Length > 0 && handler.IsDefinition) + if (IsOpenGeneric(handler)) { sb.Append(" registry.EnsureHandlerIsRegistered(typeof(") .Append(UnboundGenericName(handler)) @@ -374,6 +401,9 @@ private static void EmitTransforms( private static string FullyQualified(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + private static bool IsOpenGeneric(INamedTypeSymbol type) => + type.IsUnboundGenericType || (type.IsGenericType && type.IsDefinition); + private static string UnboundGenericName(INamedTypeSymbol type) { // Emit typeof(Foo<>) syntax for open generics. FullyQualifiedFormat produces Foo; @@ -399,31 +429,13 @@ private static string UnboundGenericName(INamedTypeSymbol type) private sealed class MarkerSymbols { - public INamedTypeSymbol? BrighterBuilder { get; } - public INamedTypeSymbol? HandleRequests { get; } - public INamedTypeSymbol? HandleRequestsAsync { get; } - public INamedTypeSymbol? MessageMapper { get; } - public INamedTypeSymbol? MessageMapperAsync { get; } - public INamedTypeSymbol? MessageTransform { get; } - public INamedTypeSymbol? MessageTransformAsync { get; } - - private MarkerSymbols( - INamedTypeSymbol? brighterBuilder, - INamedTypeSymbol? handleRequests, - INamedTypeSymbol? handleRequestsAsync, - INamedTypeSymbol? messageMapper, - INamedTypeSymbol? messageMapperAsync, - INamedTypeSymbol? messageTransform, - INamedTypeSymbol? messageTransformAsync) - { - BrighterBuilder = brighterBuilder; - HandleRequests = handleRequests; - HandleRequestsAsync = handleRequestsAsync; - MessageMapper = messageMapper; - MessageMapperAsync = messageMapperAsync; - MessageTransform = messageTransform; - MessageTransformAsync = messageTransformAsync; - } + public INamedTypeSymbol? BrighterBuilder { get; private set; } + public INamedTypeSymbol? HandleRequests { get; private set; } + public INamedTypeSymbol? HandleRequestsAsync { get; private set; } + public INamedTypeSymbol? MessageMapper { get; private set; } + public INamedTypeSymbol? MessageMapperAsync { get; private set; } + public INamedTypeSymbol? MessageTransform { get; private set; } + public INamedTypeSymbol? MessageTransformAsync { get; private set; } public bool IsValid => BrighterBuilder is not null && @@ -434,14 +446,16 @@ MessageMapperAsync is not null && MessageTransform is not null && MessageTransformAsync is not null; - public static MarkerSymbols Resolve(Compilation c) => new( - c.GetTypeByMetadataName("Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder"), - c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequests`1"), - c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequestsAsync`1"), - c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapper`1"), - c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapperAsync`1"), - c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransform"), - c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransformAsync")); + public static MarkerSymbols Resolve(Compilation c) => new() + { + BrighterBuilder = c.GetTypeByMetadataName("Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder"), + HandleRequests = c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequests`1"), + HandleRequestsAsync = c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequestsAsync`1"), + MessageMapper = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapper`1"), + MessageMapperAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapperAsync`1"), + MessageTransform = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransform"), + MessageTransformAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransformAsync"), + }; } private sealed class Registrations From 5b45c44cfbbf7b6fb88c80b32cd20c330773b605 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 18 May 2026 12:43:41 +0100 Subject: [PATCH 03/20] refactor: separate reader/writer with intermediate model, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the Kathleen Dollard incremental-generator pattern: keep semantic model reads in one place, project to a Roslyn-free intermediate model, and write source from that model as a pure function. The writer becomes trivially unit-testable and the model is structurally equatable so the incremental pipeline can cache it. Structure: - Model/RegistrationModel.cs — pure-data records describing the emit - Model/EquatableArray.cs — value-equality wrapper for caching - SemanticModelReader.cs — single point that touches Roslyn symbols - RegistrationWriter.cs — pure model -> source text - MarkerSymbols.cs / Diagnostics.cs — extracted concerns - BrighterRegistrationsGenerator.cs — thin orchestrator/pipeline only - IsExternalInit.cs — polyfill for records on netstandard2.0 Tests (new tests/Paramore.Brighter.SourceGenerators.Tests project): - RegistrationWriterTests — 8 unit tests on the pure writer - BrighterRegistrationsGeneratorTests — 4 validation tests using Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing, asserting full generated source and BRGEN001 diagnostic. Co-Authored-By: Claude Opus 4.7 --- Brighter.slnx | 1 + Directory.Packages.props | 1 + .../BrighterRegistrationsGenerator.cs | 411 +----------------- .../Diagnostics.cs | 53 +++ .../IsExternalInit.cs | 4 + .../MarkerSymbols.cs | 61 +++ .../Model/EquatableArray.cs | 76 ++++ .../Model/RegistrationModel.cs | 60 +++ .../RegistrationWriter.cs | 179 ++++++++ .../SemanticModelReader.cs | 283 ++++++++++++ .../BrighterRegistrationsGeneratorTests.cs | 210 +++++++++ ...ore.Brighter.SourceGenerators.Tests.csproj | 30 ++ .../RegistrationWriterTests.cs | 145 ++++++ 13 files changed, 1119 insertions(+), 395 deletions(-) create mode 100644 src/Paramore.Brighter.SourceGenerators/Diagnostics.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/IsExternalInit.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs create mode 100644 tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs create mode 100644 tests/Paramore.Brighter.SourceGenerators.Tests/Paramore.Brighter.SourceGenerators.Tests.csproj create mode 100644 tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs diff --git a/Brighter.slnx b/Brighter.slnx index f1d3c0a107..95e3497737 100644 --- a/Brighter.slnx +++ b/Brighter.slnx @@ -192,6 +192,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 579c9184cc..fa66b6ba1d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,6 +69,7 @@ + diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index 92e180a938..ed7f0d05fa 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -21,9 +21,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #endregion -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -32,15 +29,23 @@ THE SOFTWARE. */ namespace Paramore.Brighter.SourceGenerators; /// -/// Emits a partial implementation of a user-declared method that registers Brighter handlers -/// and message mappers from the current compilation. The goal is to replace runtime +/// Emits a partial implementation of a user-declared method that registers Brighter handlers, +/// message mappers and transforms from the current compilation. Replaces runtime /// AutoFromAssemblies reflection scanning with compile-time generated registrations. /// +/// +/// The generator follows a three-stage pipeline: +/// +/// turns Roslyn symbols into a . +/// turns the model into source text (pure function). +/// This class wires those stages into the incremental pipeline. +/// +/// Separating read from write keeps the writer unit-testable without a Compilation. +/// [Generator(LanguageNames.CSharp)] public sealed class BrighterRegistrationsGenerator : IIncrementalGenerator { private const string AttributeName = "Paramore.Brighter.BrighterRegistrationsAttribute"; - private const string ExcludeAttributeName = "Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"; private const string AttributeSource = """ // @@ -93,404 +98,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var symbols = MarkerSymbols.Resolve(compilation); if (!symbols.IsValid) - { - // Brighter not in this compilation's reference graph — nothing to do. return; - } - - var discovered = DiscoverRegistrations(compilation, symbols); foreach (var method in methods) { - if (!ValidateMethod(method, symbols, spc, out var diagnostic)) + if (!SemanticModelReader.TryBuildModel(method, compilation, symbols, out var model, out var diagnostic)) { - spc.ReportDiagnostic(diagnostic!); + if (diagnostic is not null) + spc.ReportDiagnostic(diagnostic); continue; } - var source = EmitPartial(method, discovered); - var hint = $"{method.ContainingType.ToDisplayString().Replace('.', '_')}__{method.Name}.g.cs"; - spc.AddSource(hint, SourceText.From(source, Encoding.UTF8)); + var source = RegistrationWriter.Write(model!); + spc.AddSource(model!.HintName, SourceText.From(source, Encoding.UTF8)); } }); } - - private static bool ValidateMethod( - IMethodSymbol method, - MarkerSymbols symbols, - SourceProductionContext spc, - out Diagnostic? diagnostic) - { - diagnostic = null; - - if (!method.IsPartialDefinition) - { - diagnostic = Diagnostic.Create(Diagnostics.MustBePartial, method.Locations.FirstOrDefault(), method.Name); - return false; - } - - if (!method.IsStatic) - { - diagnostic = Diagnostic.Create(Diagnostics.MustBeStatic, method.Locations.FirstOrDefault(), method.Name); - return false; - } - - if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, symbols.BrighterBuilder)) - { - diagnostic = Diagnostic.Create(Diagnostics.WrongReturnType, method.Locations.FirstOrDefault(), method.Name); - return false; - } - - if (method.Parameters.Length != 1 || - !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, symbols.BrighterBuilder)) - { - diagnostic = Diagnostic.Create(Diagnostics.WrongSignature, method.Locations.FirstOrDefault(), method.Name); - return false; - } - - return true; - } - - private static Registrations DiscoverRegistrations(Compilation compilation, MarkerSymbols symbols) - { - var result = new Registrations(); - var excludeAttr = compilation.GetTypeByMetadataName(ExcludeAttributeName); - - foreach (var type in EnumerateNamedTypes(compilation.SourceModule.GlobalNamespace)) - { - if (!IsRegistrationCandidate(type, excludeAttr)) - continue; - - ClassifyType(type, symbols, result); - } - - return result; - } - - private static bool IsRegistrationCandidate(INamedTypeSymbol type, INamedTypeSymbol? excludeAttr) - { - if (type.TypeKind != TypeKind.Class) - return false; - if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) - return false; - if (!IsReachableFromGeneratedCode(type)) - return false; - if (excludeAttr is not null && HasAttribute(type, excludeAttr)) - return false; - return true; - } - - private static bool HasAttribute(INamedTypeSymbol type, INamedTypeSymbol attribute) => - type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attribute)); - - private static void ClassifyType(INamedTypeSymbol type, MarkerSymbols symbols, Registrations result) - { - var isTransform = false; - foreach (var iface in type.AllInterfaces) - { - if (TryClassifyGenericInterface(type, iface, symbols, result)) - continue; - if (IsTransformInterface(iface, symbols)) - isTransform = true; - } - - if (isTransform && !type.IsGenericType) - result.Transforms.Add(type); - } - - private static bool TryClassifyGenericInterface( - INamedTypeSymbol type, - INamedTypeSymbol iface, - MarkerSymbols symbols, - Registrations result) - { - if (!iface.IsGenericType || iface.TypeArguments.Length != 1) - return false; - - var def = iface.OriginalDefinition; - var requestType = iface.TypeArguments[0]; - - if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequests)) - result.Handlers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequestsAsync)) - result.AsyncHandlers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapper) && !type.IsGenericType) - result.Mappers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapperAsync) && !type.IsGenericType) - result.AsyncMappers.Add((requestType, type)); - - return true; - } - - private static bool IsTransformInterface(INamedTypeSymbol iface, MarkerSymbols symbols) => - SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransform) || - SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransformAsync); - - private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) - { - for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) - { - if (t.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) - return false; - } - return true; - } - - private static IEnumerable EnumerateNamedTypes(INamespaceSymbol ns) - { - foreach (var member in ns.GetMembers()) - { - switch (member) - { - case INamespaceSymbol child: - foreach (var t in EnumerateNamedTypes(child)) - yield return t; - break; - case INamedTypeSymbol type: - yield return type; - foreach (var nested in EnumerateNested(type)) - yield return nested; - break; - } - } - } - - private static IEnumerable EnumerateNested(INamedTypeSymbol type) - { - foreach (var nested in type.GetTypeMembers()) - { - yield return nested; - foreach (var n in EnumerateNested(nested)) - yield return n; - } - } - - private static string EmitPartial(IMethodSymbol method, Registrations registrations) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine(); - - var containingType = method.ContainingType; - var ns = containingType.ContainingNamespace; - var hasNamespace = ns is { IsGlobalNamespace: false }; - - if (hasNamespace) - { - sb.Append("namespace ").Append(ns!.ToDisplayString()).AppendLine(); - sb.AppendLine("{"); - } - - var typeKeyword = containingType.IsStatic ? "static partial class" : "partial class"; - sb.Append(" ").Append(AccessibilityModifier(containingType)).Append(' ').Append(typeKeyword).Append(' ').AppendLine(containingType.Name); - sb.AppendLine(" {"); - - sb.Append(" ").Append(AccessibilityModifier(method)).Append(" static partial "); - sb.Append(FullyQualified(method.ReturnType)).Append(' ').Append(method.Name).Append('('); - if (method.IsExtensionMethod) - sb.Append("this "); - sb.Append(FullyQualified(method.Parameters[0].Type)).Append(' ').Append(method.Parameters[0].Name); - sb.AppendLine(")"); - sb.AppendLine(" {"); - - EmitHandlers(sb, method.Parameters[0].Name, registrations.Handlers, isAsync: false); - EmitHandlers(sb, method.Parameters[0].Name, registrations.AsyncHandlers, isAsync: true); - EmitMappers(sb, method.Parameters[0].Name, registrations.Mappers, registrations.AsyncMappers); - EmitTransforms(sb, method.Parameters[0].Name, registrations.Transforms); - - sb.Append(" return ").Append(method.Parameters[0].Name).AppendLine(";"); - sb.AppendLine(" }"); - sb.AppendLine(" }"); - - if (hasNamespace) - sb.AppendLine("}"); - - return sb.ToString(); - } - - private static void EmitHandlers( - StringBuilder sb, - string paramName, - List<(ITypeSymbol Request, INamedTypeSymbol Handler)> entries, - bool isAsync) - { - if (entries.Count == 0) - return; - - var callbackMethod = isAsync ? "AsyncHandlers" : "Handlers"; - sb.Append(" ").Append(paramName).Append('.').Append(callbackMethod).AppendLine("(r =>"); - sb.AppendLine(" {"); - sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); - - foreach (var (request, handler) in entries) - { - if (IsOpenGeneric(handler)) - { - sb.Append(" registry.EnsureHandlerIsRegistered(typeof(") - .Append(UnboundGenericName(handler)) - .AppendLine("));"); - } - else - { - sb.Append(" registry.Add(typeof(") - .Append(FullyQualified(request)) - .Append("), typeof(") - .Append(FullyQualified(handler)) - .AppendLine("));"); - } - } - - sb.AppendLine(" });"); - } - - private static void EmitMappers( - StringBuilder sb, - string paramName, - List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> sync, - List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> async) - { - if (sync.Count == 0 && async.Count == 0) - return; - - sb.Append(" ").Append(paramName).AppendLine(".MapperRegistry(r =>"); - sb.AppendLine(" {"); - - foreach (var (request, mapper) in sync) - { - sb.Append(" r.Add(typeof(") - .Append(FullyQualified(request)) - .Append("), typeof(") - .Append(FullyQualified(mapper)) - .AppendLine("));"); - } - - foreach (var (request, mapper) in async) - { - sb.Append(" r.AddAsync(typeof(") - .Append(FullyQualified(request)) - .Append("), typeof(") - .Append(FullyQualified(mapper)) - .AppendLine("));"); - } - - sb.AppendLine(" });"); - } - - private static void EmitTransforms( - StringBuilder sb, - string paramName, - List transforms) - { - if (transforms.Count == 0) - return; - - sb.Append(" ").Append(paramName).AppendLine(".Transforms(r =>"); - sb.AppendLine(" {"); - - foreach (var transform in transforms) - { - sb.Append(" r.Add(typeof(") - .Append(FullyQualified(transform)) - .AppendLine("));"); - } - - sb.AppendLine(" });"); - } - - private static string FullyQualified(ITypeSymbol type) => - type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - private static bool IsOpenGeneric(INamedTypeSymbol type) => - type.IsUnboundGenericType || (type.IsGenericType && type.IsDefinition); - - private static string UnboundGenericName(INamedTypeSymbol type) - { - // Emit typeof(Foo<>) syntax for open generics. FullyQualifiedFormat produces Foo; - // we need Foo<,,>-style instead. - var name = FullyQualified(type); - var lt = name.IndexOf('<'); - if (lt < 0) - return name; - var arity = type.TypeParameters.Length; - return name.Substring(0, lt + 1) + new string(',', arity - 1) + ">"; - } - - private static string AccessibilityModifier(ISymbol symbol) => symbol.DeclaredAccessibility switch - { - Accessibility.Public => "public", - Accessibility.Internal => "internal", - Accessibility.Private => "private", - Accessibility.Protected => "protected", - Accessibility.ProtectedOrInternal => "protected internal", - Accessibility.ProtectedAndInternal => "private protected", - _ => "internal" - }; - - private sealed class MarkerSymbols - { - public INamedTypeSymbol? BrighterBuilder { get; private set; } - public INamedTypeSymbol? HandleRequests { get; private set; } - public INamedTypeSymbol? HandleRequestsAsync { get; private set; } - public INamedTypeSymbol? MessageMapper { get; private set; } - public INamedTypeSymbol? MessageMapperAsync { get; private set; } - public INamedTypeSymbol? MessageTransform { get; private set; } - public INamedTypeSymbol? MessageTransformAsync { get; private set; } - - public bool IsValid => - BrighterBuilder is not null && - HandleRequests is not null && - HandleRequestsAsync is not null && - MessageMapper is not null && - MessageMapperAsync is not null && - MessageTransform is not null && - MessageTransformAsync is not null; - - public static MarkerSymbols Resolve(Compilation c) => new() - { - BrighterBuilder = c.GetTypeByMetadataName("Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder"), - HandleRequests = c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequests`1"), - HandleRequestsAsync = c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequestsAsync`1"), - MessageMapper = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapper`1"), - MessageMapperAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapperAsync`1"), - MessageTransform = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransform"), - MessageTransformAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransformAsync"), - }; - } - - private sealed class Registrations - { - public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> Handlers { get; } = new(); - public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> AsyncHandlers { get; } = new(); - public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> Mappers { get; } = new(); - public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> AsyncMappers { get; } = new(); - public List Transforms { get; } = new(); - } - - private static class Diagnostics - { - public static readonly DiagnosticDescriptor MustBePartial = new( - "BRGEN001", - "Brighter registration method must be partial", - "Method '{0}' marked with [BrighterRegistrations] must be a partial method", - "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); - - public static readonly DiagnosticDescriptor MustBeStatic = new( - "BRGEN002", - "Brighter registration method must be static", - "Method '{0}' marked with [BrighterRegistrations] must be static", - "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); - - public static readonly DiagnosticDescriptor WrongReturnType = new( - "BRGEN003", - "Brighter registration method has wrong return type", - "Method '{0}' must return IBrighterBuilder", - "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); - - public static readonly DiagnosticDescriptor WrongSignature = new( - "BRGEN004", - "Brighter registration method has wrong signature", - "Method '{0}' must accept a single IBrighterBuilder parameter", - "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); - } } diff --git a/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs b/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs new file mode 100644 index 0000000000..af5ed75809 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs @@ -0,0 +1,53 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using Microsoft.CodeAnalysis; + +namespace Paramore.Brighter.SourceGenerators; + +public static class Diagnostics +{ + public static readonly DiagnosticDescriptor MustBePartial = new( + "BRGEN001", + "Brighter registration method must be partial", + "Method '{0}' marked with [BrighterRegistrations] must be a partial method", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MustBeStatic = new( + "BRGEN002", + "Brighter registration method must be static", + "Method '{0}' marked with [BrighterRegistrations] must be static", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor WrongReturnType = new( + "BRGEN003", + "Brighter registration method has wrong return type", + "Method '{0}' must return IBrighterBuilder", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor WrongSignature = new( + "BRGEN004", + "Brighter registration method has wrong signature", + "Method '{0}' must accept a single IBrighterBuilder parameter", + "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); +} diff --git a/src/Paramore.Brighter.SourceGenerators/IsExternalInit.cs b/src/Paramore.Brighter.SourceGenerators/IsExternalInit.cs new file mode 100644 index 0000000000..f7f6857289 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/IsExternalInit.cs @@ -0,0 +1,4 @@ +// Polyfill for `init` accessors / record positional parameters on netstandard2.0. +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit { } diff --git a/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs b/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs new file mode 100644 index 0000000000..11c3bcc18e --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs @@ -0,0 +1,61 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using Microsoft.CodeAnalysis; + +namespace Paramore.Brighter.SourceGenerators; + +/// +/// Cached lookup of the Brighter framework type symbols the reader needs to recognise. +/// Returns = false when Brighter isn't in the compilation's reference graph. +/// +public sealed class MarkerSymbols +{ + public INamedTypeSymbol? BrighterBuilder { get; private set; } + public INamedTypeSymbol? HandleRequests { get; private set; } + public INamedTypeSymbol? HandleRequestsAsync { get; private set; } + public INamedTypeSymbol? MessageMapper { get; private set; } + public INamedTypeSymbol? MessageMapperAsync { get; private set; } + public INamedTypeSymbol? MessageTransform { get; private set; } + public INamedTypeSymbol? MessageTransformAsync { get; private set; } + + public bool IsValid => + BrighterBuilder is not null && + HandleRequests is not null && + HandleRequestsAsync is not null && + MessageMapper is not null && + MessageMapperAsync is not null && + MessageTransform is not null && + MessageTransformAsync is not null; + + public static MarkerSymbols Resolve(Compilation c) => new() + { + BrighterBuilder = c.GetTypeByMetadataName("Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder"), + HandleRequests = c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequests`1"), + HandleRequestsAsync = c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequestsAsync`1"), + MessageMapper = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapper`1"), + MessageMapperAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapperAsync`1"), + MessageTransform = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransform"), + MessageTransformAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransformAsync"), + }; +} diff --git a/src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs b/src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs new file mode 100644 index 0000000000..ccfb805e23 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs @@ -0,0 +1,76 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Paramore.Brighter.SourceGenerators.Model; + +/// +/// Value-equatable wrapper around an array, suitable for use in records that participate in +/// the incremental generator pipeline (where structural equality is required for caching). +/// +public sealed class EquatableArray : IEquatable>, IReadOnlyList + where T : IEquatable +{ + public static readonly EquatableArray Empty = new(Array.Empty()); + + private readonly T[] _items; + + public EquatableArray(IEnumerable items) => _items = items.ToArray(); + + public int Count => _items.Length; + + public T this[int index] => _items[index]; + + public bool Equals(EquatableArray? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + if (_items.Length != other._items.Length) return false; + for (var i = 0; i < _items.Length; i++) + { + if (!_items[i].Equals(other._items[i])) return false; + } + return true; + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + foreach (var item in _items) + hash = hash * 31 + (item is null ? 0 : item.GetHashCode()); + return hash; + } + } + + public IEnumerator GetEnumerator() => ((IEnumerable)_items).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); +} diff --git a/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs new file mode 100644 index 0000000000..95579037e3 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs @@ -0,0 +1,60 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Brighter.SourceGenerators.Model; + +/// +/// Pure-data description of what the generator should emit for a single registration method. +/// Deliberately free of Roslyn types so the writer can be unit-tested without a Compilation. +/// +public sealed record RegistrationModel( + string? Namespace, + string ContainingTypeAccessibility, + string ContainingTypeName, + bool ContainingTypeIsStatic, + string MethodAccessibility, + string MethodName, + string ReturnTypeFullyQualified, + string ParameterTypeFullyQualified, + string ParameterName, + bool IsExtensionMethod, + EquatableArray Handlers, + EquatableArray AsyncHandlers, + EquatableArray Mappers, + EquatableArray AsyncMappers, + EquatableArray Transforms, + string HintName); + +/// +/// A handler registration. For closed-generic handlers, both type names are fully qualified +/// (e.g. global::Foo.Bar). For open generics, +/// is empty and uses unbound form (e.g. global::Foo<>). +/// +public sealed record HandlerEntry( + string RequestTypeFullyQualified, + string HandlerTypeFullyQualified, + bool IsOpenGeneric); + +public sealed record MapperEntry( + string RequestTypeFullyQualified, + string MapperTypeFullyQualified); diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs new file mode 100644 index 0000000000..ad07f192d5 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -0,0 +1,179 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Text; +using Paramore.Brighter.SourceGenerators.Model; + +namespace Paramore.Brighter.SourceGenerators; + +/// +/// Pure function that turns a into the C# source for the +/// partial method implementation. Holds no Roslyn references, so it can be unit-tested +/// without constructing a Compilation. +/// +public static class RegistrationWriter +{ + public static string Write(RegistrationModel model) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var hasNamespace = !string.IsNullOrEmpty(model.Namespace); + if (hasNamespace) + { + sb.Append("namespace ").Append(model.Namespace).AppendLine(); + sb.AppendLine("{"); + } + + WriteContainingTypeOpen(sb, model); + WriteMethodSignature(sb, model); + + WriteHandlers(sb, model.ParameterName, model.Handlers, isAsync: false); + WriteHandlers(sb, model.ParameterName, model.AsyncHandlers, isAsync: true); + WriteMappers(sb, model.ParameterName, model.Mappers, model.AsyncMappers); + WriteTransforms(sb, model.ParameterName, model.Transforms); + + sb.Append(" return ").Append(model.ParameterName).AppendLine(";"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + + if (hasNamespace) + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static void WriteContainingTypeOpen(StringBuilder sb, RegistrationModel model) + { + var typeKeyword = model.ContainingTypeIsStatic ? "static partial class" : "partial class"; + sb.Append(" ") + .Append(model.ContainingTypeAccessibility) + .Append(' ') + .Append(typeKeyword) + .Append(' ') + .AppendLine(model.ContainingTypeName); + sb.AppendLine(" {"); + } + + private static void WriteMethodSignature(StringBuilder sb, RegistrationModel model) + { + sb.Append(" ").Append(model.MethodAccessibility).Append(" static partial "); + sb.Append(model.ReturnTypeFullyQualified).Append(' ').Append(model.MethodName).Append('('); + if (model.IsExtensionMethod) + sb.Append("this "); + sb.Append(model.ParameterTypeFullyQualified).Append(' ').Append(model.ParameterName); + sb.AppendLine(")"); + sb.AppendLine(" {"); + } + + private static void WriteHandlers( + StringBuilder sb, + string paramName, + EquatableArray entries, + bool isAsync) + { + if (entries.Count == 0) + return; + + var callbackMethod = isAsync ? "AsyncHandlers" : "Handlers"; + sb.Append(" ").Append(paramName).Append('.').Append(callbackMethod).AppendLine("(r =>"); + sb.AppendLine(" {"); + sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); + + foreach (var entry in entries) + { + if (entry.IsOpenGeneric) + { + sb.Append(" registry.EnsureHandlerIsRegistered(typeof(") + .Append(entry.HandlerTypeFullyQualified) + .AppendLine("));"); + } + else + { + sb.Append(" registry.Add(typeof(") + .Append(entry.RequestTypeFullyQualified) + .Append("), typeof(") + .Append(entry.HandlerTypeFullyQualified) + .AppendLine("));"); + } + } + + sb.AppendLine(" });"); + } + + private static void WriteMappers( + StringBuilder sb, + string paramName, + EquatableArray sync, + EquatableArray async) + { + if (sync.Count == 0 && async.Count == 0) + return; + + sb.Append(" ").Append(paramName).AppendLine(".MapperRegistry(r =>"); + sb.AppendLine(" {"); + + foreach (var entry in sync) + { + sb.Append(" r.Add(typeof(") + .Append(entry.RequestTypeFullyQualified) + .Append("), typeof(") + .Append(entry.MapperTypeFullyQualified) + .AppendLine("));"); + } + + foreach (var entry in async) + { + sb.Append(" r.AddAsync(typeof(") + .Append(entry.RequestTypeFullyQualified) + .Append("), typeof(") + .Append(entry.MapperTypeFullyQualified) + .AppendLine("));"); + } + + sb.AppendLine(" });"); + } + + private static void WriteTransforms( + StringBuilder sb, + string paramName, + EquatableArray transforms) + { + if (transforms.Count == 0) + return; + + sb.Append(" ").Append(paramName).AppendLine(".Transforms(r =>"); + sb.AppendLine(" {"); + + foreach (var transform in transforms) + { + sb.Append(" r.Add(typeof(") + .Append(transform) + .AppendLine("));"); + } + + sb.AppendLine(" });"); + } +} diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs new file mode 100644 index 0000000000..d07b270776 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -0,0 +1,283 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Paramore.Brighter.SourceGenerators.Model; + +namespace Paramore.Brighter.SourceGenerators; + +/// +/// Single point in the generator that touches the semantic model. Validates the user's +/// partial method, walks the source module to discover handlers/mappers/transforms, and +/// projects everything into the Roslyn-free . +/// +public static class SemanticModelReader +{ + private const string ExcludeAttributeName = "Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"; + + public static bool TryBuildModel( + IMethodSymbol method, + Compilation compilation, + MarkerSymbols symbols, + out RegistrationModel? model, + out Diagnostic? diagnostic) + { + model = null; + if (!Validate(method, symbols, out diagnostic)) + return false; + + var discovered = Discover(compilation, symbols); + model = Project(method, discovered); + return true; + } + + internal static bool Validate(IMethodSymbol method, MarkerSymbols symbols, out Diagnostic? diagnostic) + { + diagnostic = null; + var location = method.Locations.FirstOrDefault(); + + if (!method.IsPartialDefinition) + return Fail(Diagnostics.MustBePartial, location, method.Name, out diagnostic); + + if (!method.IsStatic) + return Fail(Diagnostics.MustBeStatic, location, method.Name, out diagnostic); + + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, symbols.BrighterBuilder)) + return Fail(Diagnostics.WrongReturnType, location, method.Name, out diagnostic); + + if (method.Parameters.Length != 1 || + !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, symbols.BrighterBuilder)) + return Fail(Diagnostics.WrongSignature, location, method.Name, out diagnostic); + + return true; + } + + private static bool Fail(DiagnosticDescriptor descriptor, Location? location, string methodName, out Diagnostic? diagnostic) + { + diagnostic = Diagnostic.Create(descriptor, location, methodName); + return false; + } + + private static DiscoveredSymbols Discover(Compilation compilation, MarkerSymbols symbols) + { + var result = new DiscoveredSymbols(); + var excludeAttr = compilation.GetTypeByMetadataName(ExcludeAttributeName); + + foreach (var type in EnumerateNamedTypes(compilation.SourceModule.GlobalNamespace)) + { + if (!IsRegistrationCandidate(type, excludeAttr)) + continue; + ClassifyType(type, symbols, result); + } + + return result; + } + + private static bool IsRegistrationCandidate(INamedTypeSymbol type, INamedTypeSymbol? excludeAttr) + { + if (type.TypeKind != TypeKind.Class) + return false; + if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) + return false; + if (!IsReachableFromGeneratedCode(type)) + return false; + if (excludeAttr is not null && HasAttribute(type, excludeAttr)) + return false; + return true; + } + + private static bool HasAttribute(INamedTypeSymbol type, INamedTypeSymbol attribute) => + type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attribute)); + + private static void ClassifyType(INamedTypeSymbol type, MarkerSymbols symbols, DiscoveredSymbols result) + { + var isTransform = false; + foreach (var iface in type.AllInterfaces) + { + if (TryClassifyGenericInterface(type, iface, symbols, result)) + continue; + if (IsTransformInterface(iface, symbols)) + isTransform = true; + } + + if (isTransform && !type.IsGenericType) + result.Transforms.Add(type); + } + + private static bool TryClassifyGenericInterface( + INamedTypeSymbol type, + INamedTypeSymbol iface, + MarkerSymbols symbols, + DiscoveredSymbols result) + { + if (!iface.IsGenericType || iface.TypeArguments.Length != 1) + return false; + + var def = iface.OriginalDefinition; + var requestType = iface.TypeArguments[0]; + + if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequests)) + result.Handlers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequestsAsync)) + result.AsyncHandlers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapper) && !type.IsGenericType) + result.Mappers.Add((requestType, type)); + else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapperAsync) && !type.IsGenericType) + result.AsyncMappers.Add((requestType, type)); + + return true; + } + + private static bool IsTransformInterface(INamedTypeSymbol iface, MarkerSymbols symbols) => + SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransform) || + SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransformAsync); + + private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) + { + for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) + { + if (t.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) + return false; + } + return true; + } + + private static IEnumerable EnumerateNamedTypes(INamespaceSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + switch (member) + { + case INamespaceSymbol child: + foreach (var t in EnumerateNamedTypes(child)) + yield return t; + break; + case INamedTypeSymbol type: + yield return type; + foreach (var nested in EnumerateNested(type)) + yield return nested; + break; + } + } + } + + private static IEnumerable EnumerateNested(INamedTypeSymbol type) + { + foreach (var nested in type.GetTypeMembers()) + { + yield return nested; + foreach (var n in EnumerateNested(nested)) + yield return n; + } + } + + private static RegistrationModel Project(IMethodSymbol method, DiscoveredSymbols discovered) + { + var containingType = method.ContainingType; + var ns = containingType.ContainingNamespace; + var hasNamespace = ns is { IsGlobalNamespace: false }; + + return new RegistrationModel( + Namespace: hasNamespace ? ns!.ToDisplayString() : null, + ContainingTypeAccessibility: AccessibilityModifier(containingType.DeclaredAccessibility), + ContainingTypeName: containingType.Name, + ContainingTypeIsStatic: containingType.IsStatic, + MethodAccessibility: AccessibilityModifier(method.DeclaredAccessibility), + MethodName: method.Name, + ReturnTypeFullyQualified: FullyQualified(method.ReturnType), + ParameterTypeFullyQualified: FullyQualified(method.Parameters[0].Type), + ParameterName: method.Parameters[0].Name, + IsExtensionMethod: method.IsExtensionMethod, + Handlers: new EquatableArray(discovered.Handlers.Select(MapHandler)), + AsyncHandlers: new EquatableArray(discovered.AsyncHandlers.Select(MapHandler)), + Mappers: new EquatableArray(discovered.Mappers.Select(MapMapper)), + AsyncMappers: new EquatableArray(discovered.AsyncMappers.Select(MapMapper)), + Transforms: new EquatableArray(discovered.Transforms.Select(FullyQualified)), + HintName: BuildHintName(containingType, method.Name)); + } + + private static HandlerEntry MapHandler((ITypeSymbol Request, INamedTypeSymbol Handler) entry) + { + if (IsOpenGeneric(entry.Handler)) + { + return new HandlerEntry( + RequestTypeFullyQualified: string.Empty, + HandlerTypeFullyQualified: UnboundGenericName(entry.Handler), + IsOpenGeneric: true); + } + return new HandlerEntry( + RequestTypeFullyQualified: FullyQualified(entry.Request), + HandlerTypeFullyQualified: FullyQualified(entry.Handler), + IsOpenGeneric: false); + } + + private static MapperEntry MapMapper((ITypeSymbol Request, INamedTypeSymbol Mapper) entry) => + new(FullyQualified(entry.Request), FullyQualified(entry.Mapper)); + + private static string BuildHintName(INamedTypeSymbol containingType, string methodName) + { + var raw = containingType.ToDisplayString(); + var sanitized = new System.Text.StringBuilder(raw.Length); + foreach (var ch in raw) + sanitized.Append(char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_'); + return $"{sanitized}__{methodName}.g.cs"; + } + + private static string FullyQualified(ITypeSymbol type) => + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + private static bool IsOpenGeneric(INamedTypeSymbol type) => + type.IsUnboundGenericType || (type.IsGenericType && type.IsDefinition); + + private static string UnboundGenericName(INamedTypeSymbol type) + { + var name = FullyQualified(type); + var lt = name.IndexOf('<'); + if (lt < 0) + return name; + var arity = type.TypeParameters.Length; + return name.Substring(0, lt + 1) + new string(',', arity - 1) + ">"; + } + + private static string AccessibilityModifier(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "internal" + }; + + private sealed class DiscoveredSymbols + { + public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> Handlers { get; } = new(); + public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> AsyncHandlers { get; } = new(); + public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> Mappers { get; } = new(); + public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> AsyncMappers { get; } = new(); + public List Transforms { get; } = new(); + } +} diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs new file mode 100644 index 0000000000..4570ba2d86 --- /dev/null +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs @@ -0,0 +1,210 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Paramore.Brighter.Extensions.DependencyInjection; + +namespace Paramore.Brighter.SourceGenerators.Tests; + +/// +/// End-to-end "validation" tests: drive the generator through Microsoft.CodeAnalysis.Testing, +/// supply user source, and assert on the generated source against an expected document. +/// +public class BrighterRegistrationsGeneratorTests +{ + private static CSharpSourceGeneratorTest MakeTest() => + new() + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net90, + TestState = + { + AdditionalReferences = + { + MetadataReference.CreateFromFile(typeof(IRequest).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IBrighterBuilder).Assembly.Location), + }, + }, + }; + + [Fact] + public async Task NoBrighterReference_GeneratesAttributesOnly() + { + var test = new CSharpSourceGeneratorTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net90, + TestState = + { + Sources = { "// no user code" }, + }, + }; + // Post-init output is still emitted; only the per-method registration is skipped. + test.TestState.GeneratedSources.Add(AttributeFile()); + + await test.RunAsync(); + } + + [Fact] + public async Task SyncHandler_GeneratesRegistration() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public class GreetingHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(( + typeof(BrighterRegistrationsGenerator), + "App_Registrations__AddFromThisAssembly.g.cs", + """ + // + #nullable enable + + namespace App + { + public static partial class Registrations + { + public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + { + builder.Handlers(r => + { + var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r; + registry.Add(typeof(global::App.GreetingCommand), typeof(global::App.GreetingHandler)); + }); + return builder; + } + } + } + + """)); + + await test.RunAsync(); + } + + [Fact] + public async Task ExcludedType_IsNotRegistered() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + [ExcludeFromBrighterRegistration] + public class GreetingHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(( + typeof(BrighterRegistrationsGenerator), + "App_Registrations__AddFromThisAssembly.g.cs", + """ + // + #nullable enable + + namespace App + { + public static partial class Registrations + { + public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + { + return builder; + } + } + } + + """)); + + await test.RunAsync(); + } + + [Fact] + public async Task NonPartialMethod_ReportsBRGEN001() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public static class Registrations + { + [BrighterRegistrations] + public static IBrighterBuilder AddFromThisAssembly(IBrighterBuilder builder) => builder; + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.ExpectedDiagnostics.Add( + DiagnosticResult.CompilerError("BRGEN001").WithSpan(9, 36, 9, 55).WithArguments("AddFromThisAssembly")); + + await test.RunAsync(); + } + + private static (System.Type, string, string) AttributeFile() => ( + typeof(BrighterRegistrationsGenerator), + "BrighterRegistrationsAttributes.g.cs", + """ + // + #nullable enable + namespace Paramore.Brighter + { + /// + /// Marks a partial method that the Brighter source generator will implement + /// to register handlers and message mappers discovered in the current compilation. + /// The method must be static partial, return , + /// and take a single parameter (extension methods supported). + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + internal sealed class BrighterRegistrationsAttribute : global::System.Attribute + { + } + + /// + /// Excludes a handler / mapper / transform type from automatic Brighter registration. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + internal sealed class ExcludeFromBrighterRegistrationAttribute : global::System.Attribute + { + } + } + """); +} diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/Paramore.Brighter.SourceGenerators.Tests.csproj b/tests/Paramore.Brighter.SourceGenerators.Tests/Paramore.Brighter.SourceGenerators.Tests.csproj new file mode 100644 index 0000000000..b76b823980 --- /dev/null +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/Paramore.Brighter.SourceGenerators.Tests.csproj @@ -0,0 +1,30 @@ + + + + $(BrighterTestNineOnlyTargetFrameworks) + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs new file mode 100644 index 0000000000..51527f0bfe --- /dev/null +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs @@ -0,0 +1,145 @@ +using Paramore.Brighter.SourceGenerators; +using Paramore.Brighter.SourceGenerators.Model; + +namespace Paramore.Brighter.SourceGenerators.Tests; + +public class RegistrationWriterTests +{ + private static RegistrationModel EmptyModel( + EquatableArray? handlers = null, + EquatableArray? asyncHandlers = null, + EquatableArray? mappers = null, + EquatableArray? asyncMappers = null, + EquatableArray? transforms = null) => + new( + Namespace: "MyApp", + ContainingTypeAccessibility: "public", + ContainingTypeName: "Registrations", + ContainingTypeIsStatic: true, + MethodAccessibility: "public", + MethodName: "AddFromThisAssembly", + ReturnTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", + ParameterTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", + ParameterName: "builder", + IsExtensionMethod: true, + Handlers: handlers ?? EquatableArray.Empty, + AsyncHandlers: asyncHandlers ?? EquatableArray.Empty, + Mappers: mappers ?? EquatableArray.Empty, + AsyncMappers: asyncMappers ?? EquatableArray.Empty, + Transforms: transforms ?? EquatableArray.Empty, + HintName: "MyApp_Registrations__AddFromThisAssembly.g.cs"); + + [Fact] + public void EmptyModel_EmitsScaffoldAndReturnsBuilder() + { + var output = RegistrationWriter.Write(EmptyModel()); + + Assert.Contains("namespace MyApp", output); + Assert.Contains("public static partial class Registrations", output); + Assert.Contains("public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder)", output); + Assert.Contains("return builder;", output); + Assert.DoesNotContain(".Handlers(", output); + Assert.DoesNotContain(".MapperRegistry(", output); + Assert.DoesNotContain(".Transforms(", output); + } + + [Fact] + public void ClosedHandler_EmitsRegistryAddWithFullyQualifiedTypes() + { + var model = EmptyModel(handlers: new EquatableArray(new[] + { + new HandlerEntry("global::MyApp.GreetingCommand", "global::MyApp.GreetingCommandHandler", IsOpenGeneric: false) + })); + + var output = RegistrationWriter.Write(model); + + Assert.Contains("builder.Handlers(r =>", output); + Assert.Contains("var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;", output); + Assert.Contains("registry.Add(typeof(global::MyApp.GreetingCommand), typeof(global::MyApp.GreetingCommandHandler));", output); + } + + [Fact] + public void OpenGenericHandler_EmitsEnsureHandlerIsRegistered() + { + var model = EmptyModel(handlers: new EquatableArray(new[] + { + new HandlerEntry(string.Empty, "global::MyApp.PolicyHandler<>", IsOpenGeneric: true) + })); + + var output = RegistrationWriter.Write(model); + + Assert.Contains("registry.EnsureHandlerIsRegistered(typeof(global::MyApp.PolicyHandler<>));", output); + Assert.DoesNotContain("registry.Add(typeof(", output); + } + + [Fact] + public void AsyncHandlerOnly_UsesAsyncHandlersCallback() + { + var model = EmptyModel(asyncHandlers: new EquatableArray(new[] + { + new HandlerEntry("global::MyApp.GreetingCommand", "global::MyApp.GreetingCommandHandlerAsync", IsOpenGeneric: false) + })); + + var output = RegistrationWriter.Write(model); + + Assert.Contains("builder.AsyncHandlers(r =>", output); + Assert.DoesNotContain("builder.Handlers(r =>", output); + } + + [Fact] + public void Mappers_EmitsAddAndAddAsyncCalls() + { + var model = EmptyModel( + mappers: new EquatableArray(new[] + { + new MapperEntry("global::MyApp.GreetingEvent", "global::MyApp.GreetingEventMapper") + }), + asyncMappers: new EquatableArray(new[] + { + new MapperEntry("global::MyApp.GreetingEvent", "global::MyApp.GreetingEventMapperAsync") + })); + + var output = RegistrationWriter.Write(model); + + Assert.Contains("builder.MapperRegistry(r =>", output); + Assert.Contains("r.Add(typeof(global::MyApp.GreetingEvent), typeof(global::MyApp.GreetingEventMapper));", output); + Assert.Contains("r.AddAsync(typeof(global::MyApp.GreetingEvent), typeof(global::MyApp.GreetingEventMapperAsync));", output); + } + + [Fact] + public void Transforms_EmitsTransformsCallback() + { + var model = EmptyModel(transforms: new EquatableArray(new[] + { + "global::MyApp.NoOpTransformer" + })); + + var output = RegistrationWriter.Write(model); + + Assert.Contains("builder.Transforms(r =>", output); + Assert.Contains("r.Add(typeof(global::MyApp.NoOpTransformer));", output); + } + + [Fact] + public void GlobalNamespace_OmitsNamespaceWrapper() + { + var model = EmptyModel() with { Namespace = null }; + + var output = RegistrationWriter.Write(model); + + Assert.DoesNotContain("namespace ", output); + Assert.Contains("public static partial class Registrations", output); + } + + [Fact] + public void NonStaticContainingType_EmitsPartialClassWithoutStatic() + { + var model = EmptyModel() with { ContainingTypeIsStatic = false, IsExtensionMethod = false }; + + var output = RegistrationWriter.Write(model); + + Assert.Contains("public partial class Registrations", output); + Assert.DoesNotContain("static partial class", output); + Assert.DoesNotContain("this global::Paramore.Brighter", output); + } +} From 587a9243cdc442a98697b8d67eeaf85b19b10b9d Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 18 May 2026 16:19:15 +0100 Subject: [PATCH 04/20] refactor: shrink TryBuildModel signature and split mapper classification - TryBuildModel returns a BuildResult struct instead of two out params (5 args -> 3 args) - TryClassifyGenericInterface delegates mapper classification to a TryAddMapper helper, lowering cyclomatic complexity - Introduce a small Same(ISymbol?, ISymbol?) wrapper around SymbolEqualityComparer.Default.Equals to tidy the call sites Co-Authored-By: Claude Opus 4.7 --- .../BrighterRegistrationsGenerator.cs | 11 +++-- .../SemanticModelReader.cs | 48 ++++++++++++------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index ed7f0d05fa..028dae40b2 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -102,15 +102,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) foreach (var method in methods) { - if (!SemanticModelReader.TryBuildModel(method, compilation, symbols, out var model, out var diagnostic)) + var result = SemanticModelReader.TryBuildModel(method, compilation, symbols); + if (!result.Success) { - if (diagnostic is not null) - spc.ReportDiagnostic(diagnostic); + if (result.Diagnostic is not null) + spc.ReportDiagnostic(result.Diagnostic); continue; } - var source = RegistrationWriter.Write(model!); - spc.AddSource(model!.HintName, SourceText.From(source, Encoding.UTF8)); + var model = result.Model!; + spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); } }); } diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index d07b270776..360ceede90 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -37,20 +37,20 @@ public static class SemanticModelReader { private const string ExcludeAttributeName = "Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"; - public static bool TryBuildModel( - IMethodSymbol method, - Compilation compilation, - MarkerSymbols symbols, - out RegistrationModel? model, - out Diagnostic? diagnostic) + public static BuildResult TryBuildModel(IMethodSymbol method, Compilation compilation, MarkerSymbols symbols) { - model = null; - if (!Validate(method, symbols, out diagnostic)) - return false; + if (!Validate(method, symbols, out var diagnostic)) + return BuildResult.Failure(diagnostic); var discovered = Discover(compilation, symbols); - model = Project(method, discovered); - return true; + return BuildResult.Ok(Project(method, discovered)); + } + + public readonly record struct BuildResult(RegistrationModel? Model, Diagnostic? Diagnostic) + { + public bool Success => Model is not null; + public static BuildResult Ok(RegistrationModel model) => new(model, null); + public static BuildResult Failure(Diagnostic? diagnostic) => new(null, diagnostic); } internal static bool Validate(IMethodSymbol method, MarkerSymbols symbols, out Diagnostic? diagnostic) @@ -138,18 +138,32 @@ private static bool TryClassifyGenericInterface( var def = iface.OriginalDefinition; var requestType = iface.TypeArguments[0]; - if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequests)) + if (Same(def, symbols.HandleRequests)) result.Handlers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.HandleRequestsAsync)) + else if (Same(def, symbols.HandleRequestsAsync)) result.AsyncHandlers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapper) && !type.IsGenericType) - result.Mappers.Add((requestType, type)); - else if (SymbolEqualityComparer.Default.Equals(def, symbols.MessageMapperAsync) && !type.IsGenericType) - result.AsyncMappers.Add((requestType, type)); + else if (!type.IsGenericType) + TryAddMapper(def, requestType, type, symbols, result); return true; } + private static void TryAddMapper( + INamedTypeSymbol def, + ITypeSymbol requestType, + INamedTypeSymbol type, + MarkerSymbols symbols, + DiscoveredSymbols result) + { + if (Same(def, symbols.MessageMapper)) + result.Mappers.Add((requestType, type)); + else if (Same(def, symbols.MessageMapperAsync)) + result.AsyncMappers.Add((requestType, type)); + } + + private static bool Same(ISymbol? a, ISymbol? b) => + SymbolEqualityComparer.Default.Equals(a, b); + private static bool IsTransformInterface(INamedTypeSymbol iface, MarkerSymbols symbols) => SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransform) || SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransformAsync); From fe251bb3f82c2967a4c53a7a8daad4fe8f9b8ab8 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 18 May 2026 17:52:33 +0100 Subject: [PATCH 05/20] perf: make the incremental pipeline actually incremental Rework the generator so no ISymbol, Compilation, SemanticModel, or SyntaxNode ever escapes a transform. Every value flowing through the incremental graph is now a value-equatable record, which lets Roslyn skip transforms and the source-output step when an edit doesn't change the semantically relevant shape of the compilation. Pipeline: - ForAttributeWithMetadataName.transform projects IMethodSymbol -> MethodCandidate (record holding either a MethodTarget or a DiagnosticInfo). - CreateSyntaxProvider for `class ... : Base` transforms to EquatableArray (zero, one, or many records per class). - The discovery batches are Collect()ed and Select()ed into a single flattened, sorted EquatableArray for stable, cache-friendly output. - methodCandidates.Combine(discovered) -> RegisterSourceOutput builds a RegistrationModel from pure data and emits via the unchanged writer. DiagnosticInfo + LocationInfo carry the (path, TextSpan, LinePositionSpan) needed to reconstruct a real Diagnostic at source-output time without holding non-cacheable Roslyn objects in the cache. Tests: - IncrementalCachingTests drives CSharpGeneratorDriver with trackIncrementalGeneratorSteps and asserts on IncrementalStepRunReason: trailing-comment edits and unrelated class additions yield only Cached / Unchanged outputs; adding a real handler yields Modified plus the new handler in the generated source. 15/15 tests pass. Co-Authored-By: Claude Opus 4.7 --- .../BrighterRegistrationsGenerator.cs | 96 ++++-- .../Model/PipelineModels.cs | 94 ++++++ .../Model/RegistrationModel.cs | 59 +++- .../SemanticModelReader.cs | 307 ++++++++---------- .../IncrementalCachingTests.cs | 180 ++++++++++ 5 files changed, 533 insertions(+), 203 deletions(-) create mode 100644 src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs create mode 100644 tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index 028dae40b2..2100f5b12a 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -21,26 +21,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #endregion +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using Paramore.Brighter.SourceGenerators.Model; namespace Paramore.Brighter.SourceGenerators; /// /// Emits a partial implementation of a user-declared method that registers Brighter handlers, -/// message mappers and transforms from the current compilation. Replaces runtime -/// AutoFromAssemblies reflection scanning with compile-time generated registrations. +/// message mappers and transforms from the current compilation. /// /// -/// The generator follows a three-stage pipeline: +/// The pipeline is structured so that no Roslyn semantic-model object ever escapes a transform — +/// every value flowing through the incremental graph is a value-equatable record. That is what +/// lets the generator skip work when an edit doesn't change the semantically relevant shape of +/// the compilation: /// -/// turns Roslyn symbols into a . -/// turns the model into source text (pure function). -/// This class wires those stages into the incremental pipeline. +/// Method stream: projects each +/// [BrighterRegistrations]-attributed method to a . +/// Discovery stream: projects each +/// class declaration with a base list to zero or more records, +/// collected and flattened into a single equatable array. +/// Combine + emit: each method is combined with the discovery snapshot, the +/// resulting is built from pure data, and +/// turns it into source text. /// -/// Separating read from write keeps the writer unit-testable without a Compilation. /// [Generator(LanguageNames.CSharp)] public sealed class BrighterRegistrationsGenerator : IIncrementalGenerator @@ -75,44 +85,68 @@ internal sealed class ExcludeFromBrighterRegistrationAttribute : global::System. public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterPostInitializationOutput(ctx => + context.RegisterPostInitializationOutput(static ctx => ctx.AddSource("BrighterRegistrationsAttributes.g.cs", SourceText.From(AttributeSource, Encoding.UTF8))); - var methodTargets = context.SyntaxProvider + var methodCandidates = context.SyntaxProvider .ForAttributeWithMetadataName( AttributeName, predicate: static (node, _) => node is MethodDeclarationSyntax, - transform: static (ctx, _) => (IMethodSymbol)ctx.TargetSymbol) - .Where(static m => m is not null) - .Collect(); + transform: static (ctx, ct) => SemanticModelReader.ReadMethod(ctx, ct)); - var combined = methodTargets.Combine(context.CompilationProvider); + var discovered = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is ClassDeclarationSyntax cls && cls.BaseList is not null, + transform: static (ctx, ct) => SemanticModelReader.ReadClass(ctx, ct)) + .Where(static entries => entries.Count > 0) + .Collect() + .Select(static (batches, _) => FlattenAndSort(batches)); + + var combined = methodCandidates.Combine(discovered); context.RegisterSourceOutput(combined, static (spc, pair) => { - var methods = pair.Left; - var compilation = pair.Right; + var (candidate, entries) = pair; - if (methods.IsDefaultOrEmpty) + if (candidate.Diagnostic is not null) + { + spc.ReportDiagnostic(ToDiagnostic(candidate.Diagnostic)); return; + } - var symbols = MarkerSymbols.Resolve(compilation); - if (!symbols.IsValid) + if (candidate.Method is null) return; - foreach (var method in methods) - { - var result = SemanticModelReader.TryBuildModel(method, compilation, symbols); - if (!result.Success) - { - if (result.Diagnostic is not null) - spc.ReportDiagnostic(result.Diagnostic); - continue; - } - - var model = result.Model!; - spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); - } + var model = RegistrationModel.From(candidate.Method, entries); + spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); }); } + + /// + /// Concatenate the per-file discovery batches into one equatable array, sorted for stable + /// emit order across runs. + /// + private static EquatableArray FlattenAndSort(ImmutableArray> batches) + { + IEnumerable flat = batches.SelectMany(static b => b); + var ordered = flat + .OrderBy(static e => e.Kind) + .ThenBy(static e => e.TypeFullyQualified, System.StringComparer.Ordinal) + .ThenBy(static e => e.RequestTypeFullyQualified, System.StringComparer.Ordinal); + return new EquatableArray(ordered); + } + + private static Diagnostic ToDiagnostic(DiagnosticInfo info) => Diagnostic.Create( + DescriptorFor(info.Id), + info.Location?.ToLocation() ?? Location.None, + info.Argument); + + private static DiagnosticDescriptor DescriptorFor(string id) => id switch + { + "BRGEN001" => Diagnostics.MustBePartial, + "BRGEN002" => Diagnostics.MustBeStatic, + "BRGEN003" => Diagnostics.WrongReturnType, + "BRGEN004" => Diagnostics.WrongSignature, + _ => Diagnostics.MustBePartial, + }; } diff --git a/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs b/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs new file mode 100644 index 0000000000..90a43f5b6c --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs @@ -0,0 +1,94 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Paramore.Brighter.SourceGenerators.Model; + +/// +/// Per-method projection produced by the syntax-provider transform. Holds only value-equatable +/// data so the incremental pipeline can cache it across runs. +/// +public sealed record MethodTarget( + string? Namespace, + string ContainingTypeAccessibility, + string ContainingTypeName, + bool ContainingTypeIsStatic, + string MethodAccessibility, + string MethodName, + string ReturnTypeFullyQualified, + string ParameterTypeFullyQualified, + string ParameterName, + bool IsExtensionMethod, + string HintName); + +/// The category of a discovered Brighter registration candidate. +public enum DiscoveredKind +{ + SyncHandler, + AsyncHandler, + Mapper, + AsyncMapper, + Transform, +} + +/// +/// One discovered registration candidate. Multiple entries may originate from the same class +/// (e.g., a class implementing both a sync and async handler interface). +/// +public sealed record DiscoveredEntry( + DiscoveredKind Kind, + string RequestTypeFullyQualified, + string TypeFullyQualified, + bool IsOpenGeneric); + +/// +/// Value-equatable snapshot of a Roslyn . Carried +/// through the pipeline so a diagnostic can be rebuilt at source-output time without holding +/// onto non-cacheable Roslyn objects. +/// +public sealed record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan) +{ + public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan); + + public static LocationInfo? From(Location? location) + { + if (location is null || !location.IsInSource) + return null; + var lineSpan = location.GetLineSpan(); + return new LocationInfo(lineSpan.Path, location.SourceSpan, lineSpan.Span); + } +} + +/// +/// Deferred diagnostic representation. Created in the syntax-provider transform; converted back +/// to at source-output time. +/// +public sealed record DiagnosticInfo(string Id, LocationInfo? Location, string Argument); + +/// +/// Result of reading a single [BrighterRegistrations]-attributed method: either a valid +/// , or a diagnostic describing why the method was rejected. +/// +public sealed record MethodCandidate(MethodTarget? Method, DiagnosticInfo? Diagnostic); diff --git a/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs index 95579037e3..e06b4ce1a3 100644 --- a/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs +++ b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs @@ -21,6 +21,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #endregion +using System.Collections.Generic; +using System.Linq; + namespace Paramore.Brighter.SourceGenerators.Model; /// @@ -43,7 +46,61 @@ public sealed record RegistrationModel( EquatableArray Mappers, EquatableArray AsyncMappers, EquatableArray Transforms, - string HintName); + string HintName) +{ + /// + /// Assemble a model from a per-method target and the flat list of discovered registration + /// candidates. Pure function over value-equatable inputs. + /// + public static RegistrationModel From(MethodTarget target, EquatableArray discovered) + { + var sync = new List(); + var async = new List(); + var mappers = new List(); + var asyncMappers = new List(); + var transforms = new List(); + + foreach (var entry in discovered.Distinct()) + { + switch (entry.Kind) + { + case DiscoveredKind.SyncHandler: + sync.Add(new HandlerEntry(entry.RequestTypeFullyQualified, entry.TypeFullyQualified, entry.IsOpenGeneric)); + break; + case DiscoveredKind.AsyncHandler: + async.Add(new HandlerEntry(entry.RequestTypeFullyQualified, entry.TypeFullyQualified, entry.IsOpenGeneric)); + break; + case DiscoveredKind.Mapper: + mappers.Add(new MapperEntry(entry.RequestTypeFullyQualified, entry.TypeFullyQualified)); + break; + case DiscoveredKind.AsyncMapper: + asyncMappers.Add(new MapperEntry(entry.RequestTypeFullyQualified, entry.TypeFullyQualified)); + break; + case DiscoveredKind.Transform: + transforms.Add(entry.TypeFullyQualified); + break; + } + } + + return new RegistrationModel( + target.Namespace, + target.ContainingTypeAccessibility, + target.ContainingTypeName, + target.ContainingTypeIsStatic, + target.MethodAccessibility, + target.MethodName, + target.ReturnTypeFullyQualified, + target.ParameterTypeFullyQualified, + target.ParameterName, + target.IsExtensionMethod, + new EquatableArray(sync), + new EquatableArray(async), + new EquatableArray(mappers), + new EquatableArray(asyncMappers), + new EquatableArray(transforms), + target.HintName); + } +} /// /// A handler registration. For closed-generic handlers, both type names are fully qualified diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index 360ceede90..ef9eaf5849 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -23,237 +23,208 @@ THE SOFTWARE. */ using System.Collections.Generic; using System.Linq; +using System.Text; +using System.Threading; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Paramore.Brighter.SourceGenerators.Model; namespace Paramore.Brighter.SourceGenerators; /// -/// Single point in the generator that touches the semantic model. Validates the user's -/// partial method, walks the source module to discover handlers/mappers/transforms, and -/// projects everything into the Roslyn-free . +/// Holds the two transform entry points that touch Roslyn semantic-model objects. Both produce +/// value-equatable records so the incremental pipeline can cache their output by value rather +/// than by symbol identity (which is never stable across compilations). /// public static class SemanticModelReader { - private const string ExcludeAttributeName = "Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"; - - public static BuildResult TryBuildModel(IMethodSymbol method, Compilation compilation, MarkerSymbols symbols) + /// + /// Transform for + /// .ForAttributeWithMetadataName: validates the attributed method and projects it + /// to a Roslyn-free . + /// + public static MethodCandidate ReadMethod(GeneratorAttributeSyntaxContext ctx, CancellationToken cancellationToken) { - if (!Validate(method, symbols, out var diagnostic)) - return BuildResult.Failure(diagnostic); + cancellationToken.ThrowIfCancellationRequested(); - var discovered = Discover(compilation, symbols); - return BuildResult.Ok(Project(method, discovered)); - } + if (ctx.TargetSymbol is not IMethodSymbol method) + return new MethodCandidate(null, null); - public readonly record struct BuildResult(RegistrationModel? Model, Diagnostic? Diagnostic) - { - public bool Success => Model is not null; - public static BuildResult Ok(RegistrationModel model) => new(model, null); - public static BuildResult Failure(Diagnostic? diagnostic) => new(null, diagnostic); + var markers = MarkerSymbols.Resolve(ctx.SemanticModel.Compilation); + if (!markers.IsValid) + return new MethodCandidate(null, null); + + var diagnostic = ValidateMethod(method, markers); + if (diagnostic is not null) + return new MethodCandidate(null, diagnostic); + + var target = ProjectMethod(method); + return new MethodCandidate(target, null); } - internal static bool Validate(IMethodSymbol method, MarkerSymbols symbols, out Diagnostic? diagnostic) + /// + /// Transform for + /// .CreateSyntaxProvider: inspects a single class declaration and projects any + /// Brighter-related interface implementations to value-equatable records. + /// + public static EquatableArray ReadClass(GeneratorSyntaxContext ctx, CancellationToken cancellationToken) { - diagnostic = null; - var location = method.Locations.FirstOrDefault(); + cancellationToken.ThrowIfCancellationRequested(); - if (!method.IsPartialDefinition) - return Fail(Diagnostics.MustBePartial, location, method.Name, out diagnostic); + if (ctx.Node is not ClassDeclarationSyntax cls) + return EquatableArray.Empty; - if (!method.IsStatic) - return Fail(Diagnostics.MustBeStatic, location, method.Name, out diagnostic); + if (ctx.SemanticModel.GetDeclaredSymbol(cls, cancellationToken) is not INamedTypeSymbol type) + return EquatableArray.Empty; - if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, symbols.BrighterBuilder)) - return Fail(Diagnostics.WrongReturnType, location, method.Name, out diagnostic); + if (!IsClassifiable(type)) + return EquatableArray.Empty; - if (method.Parameters.Length != 1 || - !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, symbols.BrighterBuilder)) - return Fail(Diagnostics.WrongSignature, location, method.Name, out diagnostic); + // Only emit from the "primary" partial declaration so partial classes don't get + // discovered N times. Roslyn orders DeclaringSyntaxReferences deterministically. + if (!IsPrimaryDeclaration(type, cls)) + return EquatableArray.Empty; - return true; - } + var markers = MarkerSymbols.Resolve(ctx.SemanticModel.Compilation); + if (!markers.IsValid) + return EquatableArray.Empty; - private static bool Fail(DiagnosticDescriptor descriptor, Location? location, string methodName, out Diagnostic? diagnostic) - { - diagnostic = Diagnostic.Create(descriptor, location, methodName); - return false; + if (HasExcludeAttribute(type, ctx.SemanticModel.Compilation)) + return EquatableArray.Empty; + + return new EquatableArray(ClassifyEntries(type, markers)); } - private static DiscoveredSymbols Discover(Compilation compilation, MarkerSymbols symbols) + private static DiagnosticInfo? ValidateMethod(IMethodSymbol method, MarkerSymbols markers) { - var result = new DiscoveredSymbols(); - var excludeAttr = compilation.GetTypeByMetadataName(ExcludeAttributeName); + var location = LocationInfo.From(method.Locations.FirstOrDefault()); - foreach (var type in EnumerateNamedTypes(compilation.SourceModule.GlobalNamespace)) - { - if (!IsRegistrationCandidate(type, excludeAttr)) - continue; - ClassifyType(type, symbols, result); - } + if (!method.IsPartialDefinition) + return new DiagnosticInfo(Diagnostics.MustBePartial.Id, location, method.Name); + + if (!method.IsStatic) + return new DiagnosticInfo(Diagnostics.MustBeStatic.Id, location, method.Name); - return result; + if (!Same(method.ReturnType, markers.BrighterBuilder)) + return new DiagnosticInfo(Diagnostics.WrongReturnType.Id, location, method.Name); + + if (method.Parameters.Length != 1 || !Same(method.Parameters[0].Type, markers.BrighterBuilder)) + return new DiagnosticInfo(Diagnostics.WrongSignature.Id, location, method.Name); + + return null; } - private static bool IsRegistrationCandidate(INamedTypeSymbol type, INamedTypeSymbol? excludeAttr) + private static MethodTarget ProjectMethod(IMethodSymbol method) { - if (type.TypeKind != TypeKind.Class) - return false; - if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) - return false; - if (!IsReachableFromGeneratedCode(type)) - return false; - if (excludeAttr is not null && HasAttribute(type, excludeAttr)) - return false; - return true; - } + var containingType = method.ContainingType; + var ns = containingType.ContainingNamespace; + var hasNamespace = ns is { IsGlobalNamespace: false }; - private static bool HasAttribute(INamedTypeSymbol type, INamedTypeSymbol attribute) => - type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attribute)); + return new MethodTarget( + Namespace: hasNamespace ? ns!.ToDisplayString() : null, + ContainingTypeAccessibility: AccessibilityModifier(containingType.DeclaredAccessibility), + ContainingTypeName: containingType.Name, + ContainingTypeIsStatic: containingType.IsStatic, + MethodAccessibility: AccessibilityModifier(method.DeclaredAccessibility), + MethodName: method.Name, + ReturnTypeFullyQualified: FullyQualified(method.ReturnType), + ParameterTypeFullyQualified: FullyQualified(method.Parameters[0].Type), + ParameterName: method.Parameters[0].Name, + IsExtensionMethod: method.IsExtensionMethod, + HintName: BuildHintName(containingType, method.Name)); + } - private static void ClassifyType(INamedTypeSymbol type, MarkerSymbols symbols, DiscoveredSymbols result) + private static IEnumerable ClassifyEntries(INamedTypeSymbol type, MarkerSymbols markers) { - var isTransform = false; + var seenTransform = false; foreach (var iface in type.AllInterfaces) { - if (TryClassifyGenericInterface(type, iface, symbols, result)) - continue; - if (IsTransformInterface(iface, symbols)) - isTransform = true; + var entry = TryClassifyInterface(type, iface, markers, ref seenTransform); + if (entry is not null) + yield return entry; } - if (isTransform && !type.IsGenericType) - result.Transforms.Add(type); + if (seenTransform && !type.IsGenericType) + yield return new DiscoveredEntry(DiscoveredKind.Transform, string.Empty, FullyQualified(type), IsOpenGeneric: false); } - private static bool TryClassifyGenericInterface( + private static DiscoveredEntry? TryClassifyInterface( INamedTypeSymbol type, INamedTypeSymbol iface, - MarkerSymbols symbols, - DiscoveredSymbols result) + MarkerSymbols markers, + ref bool seenTransform) { + if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync)) + { + seenTransform = true; + return null; + } + if (!iface.IsGenericType || iface.TypeArguments.Length != 1) - return false; + return null; var def = iface.OriginalDefinition; var requestType = iface.TypeArguments[0]; - if (Same(def, symbols.HandleRequests)) - result.Handlers.Add((requestType, type)); - else if (Same(def, symbols.HandleRequestsAsync)) - result.AsyncHandlers.Add((requestType, type)); - else if (!type.IsGenericType) - TryAddMapper(def, requestType, type, symbols, result); + if (Same(def, markers.HandleRequests)) + return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType); + if (Same(def, markers.HandleRequestsAsync)) + return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType); + if (Same(def, markers.MessageMapper) && !type.IsGenericType) + return new DiscoveredEntry(DiscoveredKind.Mapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); + if (Same(def, markers.MessageMapperAsync) && !type.IsGenericType) + return new DiscoveredEntry(DiscoveredKind.AsyncMapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); - return true; + return null; } - private static void TryAddMapper( - INamedTypeSymbol def, - ITypeSymbol requestType, - INamedTypeSymbol type, - MarkerSymbols symbols, - DiscoveredSymbols result) + private static DiscoveredEntry MakeHandlerEntry(DiscoveredKind kind, INamedTypeSymbol type, ITypeSymbol requestType) { - if (Same(def, symbols.MessageMapper)) - result.Mappers.Add((requestType, type)); - else if (Same(def, symbols.MessageMapperAsync)) - result.AsyncMappers.Add((requestType, type)); + if (IsOpenGeneric(type)) + return new DiscoveredEntry(kind, string.Empty, UnboundGenericName(type), IsOpenGeneric: true); + return new DiscoveredEntry(kind, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); } - private static bool Same(ISymbol? a, ISymbol? b) => - SymbolEqualityComparer.Default.Equals(a, b); - - private static bool IsTransformInterface(INamedTypeSymbol iface, MarkerSymbols symbols) => - SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransform) || - SymbolEqualityComparer.Default.Equals(iface, symbols.MessageTransformAsync); - - private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) - { - for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) - { - if (t.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) - return false; - } - return true; - } - - private static IEnumerable EnumerateNamedTypes(INamespaceSymbol ns) + private static bool IsClassifiable(INamedTypeSymbol type) { - foreach (var member in ns.GetMembers()) - { - switch (member) - { - case INamespaceSymbol child: - foreach (var t in EnumerateNamedTypes(child)) - yield return t; - break; - case INamedTypeSymbol type: - yield return type; - foreach (var nested in EnumerateNested(type)) - yield return nested; - break; - } - } + if (type.TypeKind != TypeKind.Class) + return false; + if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) + return false; + return IsReachableFromGeneratedCode(type); } - private static IEnumerable EnumerateNested(INamedTypeSymbol type) + private static bool IsPrimaryDeclaration(INamedTypeSymbol type, ClassDeclarationSyntax cls) { - foreach (var nested in type.GetTypeMembers()) - { - yield return nested; - foreach (var n in EnumerateNested(nested)) - yield return n; - } + var refs = type.DeclaringSyntaxReferences; + if (refs.Length <= 1) + return true; + var primary = refs[0]; + return primary.SyntaxTree == cls.SyntaxTree && primary.Span == cls.Span; } - private static RegistrationModel Project(IMethodSymbol method, DiscoveredSymbols discovered) + private static bool HasExcludeAttribute(INamedTypeSymbol type, Compilation compilation) { - var containingType = method.ContainingType; - var ns = containingType.ContainingNamespace; - var hasNamespace = ns is { IsGlobalNamespace: false }; - - return new RegistrationModel( - Namespace: hasNamespace ? ns!.ToDisplayString() : null, - ContainingTypeAccessibility: AccessibilityModifier(containingType.DeclaredAccessibility), - ContainingTypeName: containingType.Name, - ContainingTypeIsStatic: containingType.IsStatic, - MethodAccessibility: AccessibilityModifier(method.DeclaredAccessibility), - MethodName: method.Name, - ReturnTypeFullyQualified: FullyQualified(method.ReturnType), - ParameterTypeFullyQualified: FullyQualified(method.Parameters[0].Type), - ParameterName: method.Parameters[0].Name, - IsExtensionMethod: method.IsExtensionMethod, - Handlers: new EquatableArray(discovered.Handlers.Select(MapHandler)), - AsyncHandlers: new EquatableArray(discovered.AsyncHandlers.Select(MapHandler)), - Mappers: new EquatableArray(discovered.Mappers.Select(MapMapper)), - AsyncMappers: new EquatableArray(discovered.AsyncMappers.Select(MapMapper)), - Transforms: new EquatableArray(discovered.Transforms.Select(FullyQualified)), - HintName: BuildHintName(containingType, method.Name)); + var attr = compilation.GetTypeByMetadataName("Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"); + if (attr is null) + return false; + return type.GetAttributes().Any(a => Same(a.AttributeClass, attr)); } - private static HandlerEntry MapHandler((ITypeSymbol Request, INamedTypeSymbol Handler) entry) + private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) { - if (IsOpenGeneric(entry.Handler)) + for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) { - return new HandlerEntry( - RequestTypeFullyQualified: string.Empty, - HandlerTypeFullyQualified: UnboundGenericName(entry.Handler), - IsOpenGeneric: true); + if (t.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) + return false; } - return new HandlerEntry( - RequestTypeFullyQualified: FullyQualified(entry.Request), - HandlerTypeFullyQualified: FullyQualified(entry.Handler), - IsOpenGeneric: false); + return true; } - private static MapperEntry MapMapper((ITypeSymbol Request, INamedTypeSymbol Mapper) entry) => - new(FullyQualified(entry.Request), FullyQualified(entry.Mapper)); - private static string BuildHintName(INamedTypeSymbol containingType, string methodName) { var raw = containingType.ToDisplayString(); - var sanitized = new System.Text.StringBuilder(raw.Length); + var sanitized = new StringBuilder(raw.Length); foreach (var ch in raw) sanitized.Append(char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_'); return $"{sanitized}__{methodName}.g.cs"; @@ -275,6 +246,9 @@ private static string UnboundGenericName(INamedTypeSymbol type) return name.Substring(0, lt + 1) + new string(',', arity - 1) + ">"; } + private static bool Same(ISymbol? a, ISymbol? b) => + SymbolEqualityComparer.Default.Equals(a, b); + private static string AccessibilityModifier(Accessibility accessibility) => accessibility switch { Accessibility.Public => "public", @@ -285,13 +259,4 @@ private static string UnboundGenericName(INamedTypeSymbol type) Accessibility.ProtectedAndInternal => "private protected", _ => "internal" }; - - private sealed class DiscoveredSymbols - { - public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> Handlers { get; } = new(); - public List<(ITypeSymbol Request, INamedTypeSymbol Handler)> AsyncHandlers { get; } = new(); - public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> Mappers { get; } = new(); - public List<(ITypeSymbol Request, INamedTypeSymbol Mapper)> AsyncMappers { get; } = new(); - public List Transforms { get; } = new(); - } } diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs new file mode 100644 index 0000000000..77b6a4d120 --- /dev/null +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs @@ -0,0 +1,180 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Paramore.Brighter.SourceGenerators.Tests; + +/// +/// Verifies that the incremental pipeline's outputs are cached when an edit doesn't change +/// the semantically relevant shape of the compilation. These tests exercise the actual +/// IncrementalStepRunReason values that Roslyn records — the contract that "the same input +/// produces a cached result" is what determines real-world IDE responsiveness. +/// +public class IncrementalCachingTests +{ + private const string UserSource = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public class GreetingHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + private static (GeneratorDriver Driver, Compilation Compilation) RunInitial(string source) + { + var references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Paramore.Brighter.IRequest).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder).Assembly.Location), + }; + var compilation = CSharpCompilation.Create( + "TestAsm", + new[] { CSharpSyntaxTree.ParseText(source) }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new BrighterRegistrationsGenerator().AsSourceGenerator() }, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + + driver = driver.RunGenerators(compilation); + return (driver, compilation); + } + + [Fact] + public void UnrelatedSyntaxEdit_CachesAllOutputs() + { + var (driver, compilation) = RunInitial(UserSource); + + // Touch the source: add a blank line at the bottom. Nothing semantically relevant changes. + var edited = compilation.SyntaxTrees.Single(); + var editedText = edited.GetText().ToString() + "\n// trailing comment\n"; + var newCompilation = compilation + .ReplaceSyntaxTree(edited, CSharpSyntaxTree.ParseText(editedText)); + + driver = driver.RunGenerators(newCompilation); + + var runResult = driver.GetRunResult(); + var stepReasons = runResult.Results + .Single() + .TrackedOutputSteps + .SelectMany(kv => kv.Value) + .SelectMany(step => step.Outputs) + .Select(output => output.Reason) + .ToArray(); + + Assert.NotEmpty(stepReasons); + Assert.All(stepReasons, reason => + Assert.True( + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Expected Cached or Unchanged but got {reason}")); + } + + [Fact] + public void AddingUnrelatedClass_CachesRegistrationOutput() + { + var (driver, compilation) = RunInitial(UserSource); + + // Add a completely unrelated class in a new tree — nothing implementing handler/mapper. + var newTree = CSharpSyntaxTree.ParseText(""" + namespace App.Other; + + public class Bystander + { + public int X { get; set; } + } + """); + var newCompilation = compilation.AddSyntaxTrees(newTree); + + driver = driver.RunGenerators(newCompilation); + + var runResult = driver.GetRunResult(); + var allReasons = runResult.Results + .Single() + .TrackedSteps + .SelectMany(kv => kv.Value) + .SelectMany(step => step.Outputs) + .Select(output => output.Reason) + .ToArray(); + + // The new class adds a fresh "ReadClass" run (New), but downstream of FlattenAndSort the + // discovered list must be Unchanged because the new class produces zero entries. + Assert.Contains(IncrementalStepRunReason.Unchanged, allReasons); + + // And the final generated source step shouldn't be Modified. + var sourceReasons = runResult.Results + .Single() + .TrackedOutputSteps + .SelectMany(kv => kv.Value) + .SelectMany(step => step.Outputs) + .Select(o => o.Reason) + .ToArray(); + Assert.All(sourceReasons, reason => + Assert.True( + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Expected Cached or Unchanged for output step but got {reason}")); + } + + [Fact] + public void AddingNewHandler_RegeneratesRegistrationOutput() + { + var (driver, compilation) = RunInitial(UserSource); + + // Add a NEW handler in a separate file. This must invalidate the output. + var newTree = CSharpSyntaxTree.ParseText(""" + using Paramore.Brighter; + + namespace App; + + public class OtherCommand : Command + { + public OtherCommand() : base(System.Guid.NewGuid()) { } + } + + public class OtherHandler : RequestHandler + { + public override OtherCommand Handle(OtherCommand command) => base.Handle(command); + } + """); + var newCompilation = compilation.AddSyntaxTrees(newTree); + + driver = driver.RunGenerators(newCompilation); + + var runResult = driver.GetRunResult(); + var sourceReasons = runResult.Results + .Single() + .TrackedOutputSteps + .SelectMany(kv => kv.Value) + .SelectMany(step => step.Outputs) + .Select(o => o.Reason) + .ToArray(); + + // At least one source output must be Modified (the registration file). + Assert.Contains(IncrementalStepRunReason.Modified, sourceReasons); + + // And the generated source itself must mention the new handler. + var generated = runResult.Results.Single().GeneratedSources + .First(gs => gs.HintName.EndsWith("__AddFromThisAssembly.g.cs")) + .SourceText.ToString(); + Assert.Contains("global::App.OtherHandler", generated); + } +} From af0f07006fe214d05a87b6490a2fee91a96021dd Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 18 May 2026 19:03:35 +0100 Subject: [PATCH 06/20] feat: auto-generate BrighterAssemblyRegistrations on direct PackageReference A second pipeline synthesises an `internal static class BrighterAssemblyRegistrations` with an `AddFromThisAssembly()` extension on IBrighterBuilder, populated from the same DiscoveredEntry stream as the attribute-based path. Consumers no longer need to hand-write a partial class. Gating: - The generator only emits the auto class when the MSBuild property BrighterAutoRegistration=true is visible via AnalyzerConfigOptionsProvider. - The new build/Paramore.Brighter.SourceGenerators.props sets that property and declares it CompilerVisible. Because NuGet only applies build/ to *direct* PackageReferences, transitive consumers won't see the property and the auto class won't be generated for them. - Users opt out per project with false. Writer: - RegistrationModel/MethodTarget gain an IsPartial flag (default true). The writer omits `partial` on both the class and the method when IsPartial=false, so the auto class is a normal static class. Sample: - HelloWorld drops the hand-written BrighterRegistrations.cs and just calls builder.Services.AddBrighter().AddFromThisAssembly(). It opts in via true + CompilerVisibleProperty, because ProjectReference scenarios don't pick up the package's build/ props automatically. Tests: - AutoRegistrationTests verifies property=true emits the class with discovered handlers, property=false suppresses emission, and property-missing (the transitive-consumer scenario) also suppresses emission. 18/18 tests pass. Co-Authored-By: Claude Opus 4.7 --- .../HelloWorld/BrighterRegistrations.cs | 10 -- .../HelloWorld/HelloWorld.csproj | 10 ++ .../BrighterRegistrationsGenerator.cs | 53 ++++++++ .../Model/PipelineModels.cs | 3 +- .../Model/RegistrationModel.cs | 6 +- .../Paramore.Brighter.SourceGenerators.csproj | 5 + .../RegistrationWriter.cs | 12 +- .../Paramore.Brighter.SourceGenerators.props | 19 +++ .../AutoRegistrationTests.cs | 118 ++++++++++++++++++ 9 files changed, 221 insertions(+), 15 deletions(-) delete mode 100644 samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs create mode 100644 src/Paramore.Brighter.SourceGenerators/build/Paramore.Brighter.SourceGenerators.props create mode 100644 tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs diff --git a/samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs b/samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs deleted file mode 100644 index 664616e7b3..0000000000 --- a/samples/CommandProcessor/HelloWorld/BrighterRegistrations.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Paramore.Brighter; -using Paramore.Brighter.Extensions.DependencyInjection; - -namespace HelloWorld; - -public static partial class BrighterRegistrations -{ - [BrighterRegistrations] - public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); -} diff --git a/samples/CommandProcessor/HelloWorld/HelloWorld.csproj b/samples/CommandProcessor/HelloWorld/HelloWorld.csproj index 8ff1554fcf..7911323f65 100644 --- a/samples/CommandProcessor/HelloWorld/HelloWorld.csproj +++ b/samples/CommandProcessor/HelloWorld/HelloWorld.csproj @@ -2,7 +2,17 @@ net9.0 Exe + + true + + + + diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index 2100f5b12a..7914e1f3bc 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -56,6 +56,10 @@ namespace Paramore.Brighter.SourceGenerators; public sealed class BrighterRegistrationsGenerator : IIncrementalGenerator { private const string AttributeName = "Paramore.Brighter.BrighterRegistrationsAttribute"; + private const string AutoRegistrationProperty = "build_property.BrighterAutoRegistration"; + private const string AutoClassNamespace = "Paramore.Brighter.Extensions.DependencyInjection"; + private const string AutoClassName = "BrighterAssemblyRegistrations"; + private const string AutoMethodName = "AddFromThisAssembly"; private const string AttributeSource = """ // @@ -120,8 +124,57 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var model = RegistrationModel.From(candidate.Method, entries); spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); }); + + RegisterAutoRegistration(context, discovered); } + /// + /// Second pipeline that synthesises an internal static class BrighterAssemblyRegistrations + /// with an AddFromThisAssembly extension method, when the BrighterAutoRegistration + /// MSBuild property is true (set by the build/ props file shipped with the package, so it + /// only flows to direct PackageReferences — not transitively). + /// + private static void RegisterAutoRegistration( + IncrementalGeneratorInitializationContext context, + IncrementalValueProvider> discovered) + { + var autoEnabled = context.AnalyzerConfigOptionsProvider + .Select(static (provider, _) => + provider.GlobalOptions.TryGetValue(AutoRegistrationProperty, out var v) + && bool.TryParse(v, out var b) + && b); + + var brighterAvailable = context.CompilationProvider + .Select(static (c, _) => MarkerSymbols.Resolve(c).IsValid); + + var autoInputs = autoEnabled.Combine(brighterAvailable).Combine(discovered); + + context.RegisterSourceOutput(autoInputs, static (spc, pair) => + { + var ((enabled, available), entries) = pair; + if (!enabled || !available) + return; + + var target = BuildAutoTarget(); + var model = RegistrationModel.From(target, entries); + spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); + }); + } + + private static MethodTarget BuildAutoTarget() => new( + Namespace: AutoClassNamespace, + ContainingTypeAccessibility: "internal", + ContainingTypeName: AutoClassName, + ContainingTypeIsStatic: true, + MethodAccessibility: "public", + MethodName: AutoMethodName, + ReturnTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", + ParameterTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", + ParameterName: "builder", + IsExtensionMethod: true, + HintName: $"{AutoClassName}__{AutoMethodName}.g.cs", + IsPartial: false); + /// /// Concatenate the per-file discovery batches into one equatable array, sorted for stable /// emit order across runs. diff --git a/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs b/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs index 90a43f5b6c..4f740427dd 100644 --- a/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs +++ b/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs @@ -41,7 +41,8 @@ public sealed record MethodTarget( string ParameterTypeFullyQualified, string ParameterName, bool IsExtensionMethod, - string HintName); + string HintName, + bool IsPartial = true); /// The category of a discovered Brighter registration candidate. public enum DiscoveredKind diff --git a/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs index e06b4ce1a3..f7908550a6 100644 --- a/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs +++ b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs @@ -46,7 +46,8 @@ public sealed record RegistrationModel( EquatableArray Mappers, EquatableArray AsyncMappers, EquatableArray Transforms, - string HintName) + string HintName, + bool IsPartial = true) { /// /// Assemble a model from a per-method target and the flat list of discovered registration @@ -98,7 +99,8 @@ public static RegistrationModel From(MethodTarget target, EquatableArray(mappers), new EquatableArray(asyncMappers), new EquatableArray(transforms), - target.HintName); + target.HintName, + target.IsPartial); } } diff --git a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj index f471c830f1..8d1dd3179e 100644 --- a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj +++ b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj @@ -21,4 +21,9 @@ + + + + + diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index ad07f192d5..b5bfeb54d4 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -67,7 +67,13 @@ public static string Write(RegistrationModel model) private static void WriteContainingTypeOpen(StringBuilder sb, RegistrationModel model) { - var typeKeyword = model.ContainingTypeIsStatic ? "static partial class" : "partial class"; + var typeKeyword = (model.ContainingTypeIsStatic, model.IsPartial) switch + { + (true, true) => "static partial class", + (true, false) => "static class", + (false, true) => "partial class", + (false, false) => "class", + }; sb.Append(" ") .Append(model.ContainingTypeAccessibility) .Append(' ') @@ -79,7 +85,9 @@ private static void WriteContainingTypeOpen(StringBuilder sb, RegistrationModel private static void WriteMethodSignature(StringBuilder sb, RegistrationModel model) { - sb.Append(" ").Append(model.MethodAccessibility).Append(" static partial "); + sb.Append(" ").Append(model.MethodAccessibility).Append(" static "); + if (model.IsPartial) + sb.Append("partial "); sb.Append(model.ReturnTypeFullyQualified).Append(' ').Append(model.MethodName).Append('('); if (model.IsExtensionMethod) sb.Append("this "); diff --git a/src/Paramore.Brighter.SourceGenerators/build/Paramore.Brighter.SourceGenerators.props b/src/Paramore.Brighter.SourceGenerators/build/Paramore.Brighter.SourceGenerators.props new file mode 100644 index 0000000000..93715545d7 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/build/Paramore.Brighter.SourceGenerators.props @@ -0,0 +1,19 @@ + + + + + + + true + + + + + + + diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs new file mode 100644 index 0000000000..2aa808e618 --- /dev/null +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Paramore.Brighter.SourceGenerators.Tests; + +/// +/// Exercises the auto-registration pipeline: gated on the BrighterAutoRegistration MSBuild +/// property (made CompilerVisible by the build/ props file shipped with the package). +/// +public class AutoRegistrationTests +{ + private const string UserSource = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public class GreetingHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + """; + + private static GeneratorDriverRunResult Run(string source, string? autoRegistrationValue) + { + var references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Paramore.Brighter.IRequest).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder).Assembly.Location), + }; + var compilation = CSharpCompilation.Create( + "TestAsm", + new[] { CSharpSyntaxTree.ParseText(source) }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var globalOptions = autoRegistrationValue is null + ? ImmutableDictionary.Empty + : ImmutableDictionary.Empty + .Add("build_property.BrighterAutoRegistration", autoRegistrationValue); + + var optionsProvider = new StubOptionsProvider(globalOptions); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new[] { new BrighterRegistrationsGenerator().AsSourceGenerator() }, + additionalTexts: ImmutableArray.Empty, + parseOptions: CSharpParseOptions.Default, + optionsProvider: optionsProvider); + + return driver.RunGenerators(compilation).GetRunResult(); + } + + [Fact] + public void PropertyTrue_GeneratesBrighterAssemblyRegistrations() + { + var result = Run(UserSource, "true"); + + var generated = result.Results.Single().GeneratedSources + .Single(g => g.HintName == "BrighterAssemblyRegistrations__AddFromThisAssembly.g.cs") + .SourceText.ToString(); + + Assert.Contains("namespace Paramore.Brighter.Extensions.DependencyInjection", generated); + Assert.Contains("internal static class BrighterAssemblyRegistrations", generated); + Assert.Contains("public static global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly", generated); + Assert.Contains("registry.Add(typeof(global::App.GreetingCommand), typeof(global::App.GreetingHandler));", generated); + Assert.DoesNotContain("partial", generated); + } + + [Fact] + public void PropertyFalse_DoesNotGenerateAutoClass() + { + var result = Run(UserSource, "false"); + + Assert.DoesNotContain( + result.Results.Single().GeneratedSources, + g => g.HintName == "BrighterAssemblyRegistrations__AddFromThisAssembly.g.cs"); + } + + [Fact] + public void PropertyMissing_DoesNotGenerateAutoClass() + { + // Simulates a transitive consumer that picked up the analyzer but not the build/ props. + var result = Run(UserSource, autoRegistrationValue: null); + + Assert.DoesNotContain( + result.Results.Single().GeneratedSources, + g => g.HintName == "BrighterAssemblyRegistrations__AddFromThisAssembly.g.cs"); + } + + private sealed class StubOptionsProvider : AnalyzerConfigOptionsProvider + { + private readonly StubOptions _global; + public StubOptionsProvider(ImmutableDictionary globals) => _global = new StubOptions(globals); + public override AnalyzerConfigOptions GlobalOptions => _global; + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => StubOptions.Empty; + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => StubOptions.Empty; + + private sealed class StubOptions : AnalyzerConfigOptions + { + public static readonly StubOptions Empty = new(ImmutableDictionary.Empty); + private readonly ImmutableDictionary _values; + public StubOptions(ImmutableDictionary values) => _values = values; + public override bool TryGetValue(string key, out string value) => _values.TryGetValue(key, out value!); + } + } +} From 1979663233e9a9d1424ccda9ac3a28df043bf72b Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Tue, 19 May 2026 00:20:48 +0100 Subject: [PATCH 07/20] fix: address PR review findings Generator surface: - IBrighterBuilder.Transforms is now a BrighterBuilderExtensions extension method, not an interface member, so the original PR no longer makes a binary-breaking change to the public IBrighterBuilder contract. - Closed-generic handlers emit r.Register() via the public IAm(Async)SubscriberRegistry interfaces, so the cast to the concrete ServiceCollectionSubscriberRegistry only appears when at least one open generic is present (and then exactly once). - Generated source calls Transforms statically via the fully-qualified BrighterBuilderExtensions, so it doesn't depend on the consumer adding a `using`. - Auto-generated AddFromThisAssembly is `internal` (was `public` on an internal class, which was noisy). Reader / model hardening: - ExcludeFromBrighterRegistrationAttribute is folded into MarkerSymbols so the symbol is resolved once per compilation instead of per class. - IsPrimaryDeclaration sorts DeclaringSyntaxReferences by (FilePath, Start) explicitly rather than relying on undocumented Roslyn ordering. - ReadClass returns a DiscoveryBatch carrying both entries and diagnostics, so a discovery-time warning (BRGEN005) can travel through the cached pipeline alongside the entries. - New BRGEN005 warning fires when a generic class implements a Brighter mapper or transform interface (previously silently dropped). - BuildHintName appends an FNV-1a hash of the original display string so types differing only in non-identifier characters (e.g. Foo.Bar vs Foo_Bar) can't collide. - EquatableArray.Equals/GetHashCode use EqualityComparer.Default, so the public type doesn't NRE if a future caller passes a null element. - DescriptorFor throws on an unknown diagnostic id instead of defaulting to MustBePartial, catching missed updates at test time. Tests (27 passing): - End-to-end tests added for BRGEN002 / BRGEN003 / BRGEN004 / BRGEN005. - End-to-end test for mapper + async mapper + transform discovery. - End-to-end test for partial-class dedup (a handler split across two syntax trees registers exactly once). - End-to-end test that IsClassifiable filters out abstract and private nested handlers. - AutoRegistrationTests asserts Empty diagnostics on the happy path. - RegistrationWriter unit tests updated for the new emit shape and a new case verifies the implementation cast is emitted exactly once when both closed and open-generic handlers are present. Co-Authored-By: Claude Opus 4.7 --- .../BrighterBuilderExtensions.cs | 60 +++ .../IBrighterBuilder.cs | 10 - .../BrighterRegistrationsGenerator.cs | 28 +- .../Diagnostics.cs | 6 + .../MarkerSymbols.cs | 2 + .../Model/EquatableArray.cs | 6 +- .../Model/PipelineModels.cs | 13 + .../RegistrationWriter.cs | 40 +- .../SemanticModelReader.cs | 106 +++-- .../AutoRegistrationTests.cs | 9 +- .../BrighterRegistrationsGeneratorTests.cs | 398 +++++++++++++++++- .../RegistrationWriterTests.cs | 28 +- 12 files changed, 633 insertions(+), 73 deletions(-) create mode 100644 src/Paramore.Brighter.Extensions.DependencyInjection/BrighterBuilderExtensions.cs diff --git a/src/Paramore.Brighter.Extensions.DependencyInjection/BrighterBuilderExtensions.cs b/src/Paramore.Brighter.Extensions.DependencyInjection/BrighterBuilderExtensions.cs new file mode 100644 index 0000000000..b6191ecc5c --- /dev/null +++ b/src/Paramore.Brighter.Extensions.DependencyInjection/BrighterBuilderExtensions.cs @@ -0,0 +1,60 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; + +namespace Paramore.Brighter.Extensions.DependencyInjection +{ + /// + /// Extension methods on that are deliberately kept off the + /// interface so they can be added without making a binary-breaking change to public API for + /// downstream implementers. + /// + public static class BrighterBuilderExtensions + { + /// + /// Register transforms with the built-in transformer registry. Symmetric with + /// and , + /// intended for callers that want to add transforms explicitly (for example from a + /// source generator) rather than via assembly scanning. + /// + /// The Brighter builder. + /// A callback to register transforms. + /// The builder, to allow chaining. + /// + /// Thrown if is not the + /// implementation that owns a . + /// + public static IBrighterBuilder Transforms(this IBrighterBuilder builder, Action registerTransforms) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + if (registerTransforms == null) throw new ArgumentNullException(nameof(registerTransforms)); + + if (builder is ServiceCollectionBrighterBuilder concrete) + return concrete.Transforms(registerTransforms); + + throw new InvalidOperationException( + $"The {nameof(Transforms)} extension requires {nameof(ServiceCollectionBrighterBuilder)}; received {builder.GetType().FullName}."); + } + } +} diff --git a/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs b/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs index 1120df07c3..5e37519231 100644 --- a/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs +++ b/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs @@ -100,16 +100,6 @@ public interface IBrighterBuilder /// This builder, allows chaining calls IBrighterBuilder TransformsFromAssemblies(IEnumerable assemblies); - /// - /// Register transforms with the built-in transformer registry. Symmetric with - /// and - /// , intended for callers that want to add transforms - /// explicitly (for example from a source generator) rather than via assembly scanning. - /// - /// A callback to register transforms - /// This builder, allows chaining calls - IBrighterBuilder Transforms(Action registerTransforms); - /// /// [Obsolete] Gets or sets the legacy policy registry used for the command processor and event bus. /// diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index 7914e1f3bc..bef75427f3 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -98,13 +98,24 @@ public void Initialize(IncrementalGeneratorInitializationContext context) predicate: static (node, _) => node is MethodDeclarationSyntax, transform: static (ctx, ct) => SemanticModelReader.ReadMethod(ctx, ct)); - var discovered = context.SyntaxProvider + var discoveryBatches = context.SyntaxProvider .CreateSyntaxProvider( predicate: static (node, _) => node is ClassDeclarationSyntax cls && cls.BaseList is not null, transform: static (ctx, ct) => SemanticModelReader.ReadClass(ctx, ct)) - .Where(static entries => entries.Count > 0) - .Collect() - .Select(static (batches, _) => FlattenAndSort(batches)); + .Where(static batch => !batch.IsEmpty) + .Collect(); + + var discovered = discoveryBatches.Select(static (batches, _) => + FlattenAndSort(batches.Select(static b => b.Entries))); + + var discoveryDiagnostics = discoveryBatches.Select(static (batches, _) => + new EquatableArray(batches.SelectMany(static b => (IEnumerable)b.Diagnostics))); + + context.RegisterSourceOutput(discoveryDiagnostics, static (spc, diagnostics) => + { + foreach (var info in diagnostics) + spc.ReportDiagnostic(ToDiagnostic(info)); + }); var combined = methodCandidates.Combine(discovered); @@ -166,7 +177,7 @@ private static void RegisterAutoRegistration( ContainingTypeAccessibility: "internal", ContainingTypeName: AutoClassName, ContainingTypeIsStatic: true, - MethodAccessibility: "public", + MethodAccessibility: "internal", MethodName: AutoMethodName, ReturnTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", ParameterTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", @@ -179,9 +190,9 @@ private static void RegisterAutoRegistration( /// Concatenate the per-file discovery batches into one equatable array, sorted for stable /// emit order across runs. /// - private static EquatableArray FlattenAndSort(ImmutableArray> batches) + private static EquatableArray FlattenAndSort(IEnumerable> batches) { - IEnumerable flat = batches.SelectMany(static b => b); + IEnumerable flat = batches.SelectMany(static b => (IEnumerable)b); var ordered = flat .OrderBy(static e => e.Kind) .ThenBy(static e => e.TypeFullyQualified, System.StringComparer.Ordinal) @@ -200,6 +211,7 @@ private static Diagnostic ToDiagnostic(DiagnosticInfo info) => Diagnostic.Create "BRGEN002" => Diagnostics.MustBeStatic, "BRGEN003" => Diagnostics.WrongReturnType, "BRGEN004" => Diagnostics.WrongSignature, - _ => Diagnostics.MustBePartial, + "BRGEN005" => Diagnostics.GenericMapperOrTransformIgnored, + _ => throw new System.InvalidOperationException($"Unknown Brighter diagnostic id '{id}' — DescriptorFor needs updating."), }; } diff --git a/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs b/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs index af5ed75809..a84507b44f 100644 --- a/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs +++ b/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs @@ -50,4 +50,10 @@ public static class Diagnostics "Brighter registration method has wrong signature", "Method '{0}' must accept a single IBrighterBuilder parameter", "Brighter", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor GenericMapperOrTransformIgnored = new( + "BRGEN005", + "Generic message mappers and transforms are not registered", + "Generic type '{0}' implements a Brighter mapper or transform interface but won't be auto-registered; close the generic, write a non-generic wrapper, or mark it with [ExcludeFromBrighterRegistration]", + "Brighter", DiagnosticSeverity.Warning, isEnabledByDefault: true); } diff --git a/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs b/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs index 11c3bcc18e..f404dd67cf 100644 --- a/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs +++ b/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs @@ -38,6 +38,7 @@ public sealed class MarkerSymbols public INamedTypeSymbol? MessageMapperAsync { get; private set; } public INamedTypeSymbol? MessageTransform { get; private set; } public INamedTypeSymbol? MessageTransformAsync { get; private set; } + public INamedTypeSymbol? ExcludeAttribute { get; private set; } public bool IsValid => BrighterBuilder is not null && @@ -57,5 +58,6 @@ MessageTransform is not null && MessageMapperAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageMapperAsync`1"), MessageTransform = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransform"), MessageTransformAsync = c.GetTypeByMetadataName("Paramore.Brighter.IAmAMessageTransformAsync"), + ExcludeAttribute = c.GetTypeByMetadataName("Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"), }; } diff --git a/src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs b/src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs index ccfb805e23..073c8cbaaa 100644 --- a/src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs +++ b/src/Paramore.Brighter.SourceGenerators/Model/EquatableArray.cs @@ -50,9 +50,10 @@ public bool Equals(EquatableArray? other) if (other is null) return false; if (ReferenceEquals(this, other)) return true; if (_items.Length != other._items.Length) return false; + var comparer = EqualityComparer.Default; for (var i = 0; i < _items.Length; i++) { - if (!_items[i].Equals(other._items[i])) return false; + if (!comparer.Equals(_items[i], other._items[i])) return false; } return true; } @@ -63,9 +64,10 @@ public override int GetHashCode() { unchecked { + var comparer = EqualityComparer.Default; var hash = 17; foreach (var item in _items) - hash = hash * 31 + (item is null ? 0 : item.GetHashCode()); + hash = hash * 31 + (item is null ? 0 : comparer.GetHashCode(item)); return hash; } } diff --git a/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs b/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs index 4f740427dd..49ecf1ad98 100644 --- a/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs +++ b/src/Paramore.Brighter.SourceGenerators/Model/PipelineModels.cs @@ -93,3 +93,16 @@ public sealed record DiagnosticInfo(string Id, LocationInfo? Location, string Ar /// , or a diagnostic describing why the method was rejected. /// public sealed record MethodCandidate(MethodTarget? Method, DiagnosticInfo? Diagnostic); + +/// +/// Per-class output from the discovery transform: zero or more registration entries, plus zero +/// or more diagnostics (e.g. BRGEN005 when a generic mapper/transform is observed). +/// +public sealed record DiscoveryBatch( + EquatableArray Entries, + EquatableArray Diagnostics) +{ + public static readonly DiscoveryBatch Empty = new(EquatableArray.Empty, EquatableArray.Empty); + + public bool IsEmpty => Entries.Count == 0 && Diagnostics.Count == 0; +} diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index b5bfeb54d4..72d03d0c60 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -106,26 +106,40 @@ private static void WriteHandlers( return; var callbackMethod = isAsync ? "AsyncHandlers" : "Handlers"; + var registerMethod = isAsync ? "RegisterAsync" : "Register"; + var hasOpenGeneric = false; + foreach (var entry in entries) + { + if (entry.IsOpenGeneric) { hasOpenGeneric = true; break; } + } + sb.Append(" ").Append(paramName).Append('.').Append(callbackMethod).AppendLine("(r =>"); sb.AppendLine(" {"); - sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); + // For closed-generic handlers, use the strongly-typed Register() method + // available on the public interface — no implementation cast needed. foreach (var entry in entries) { - if (entry.IsOpenGeneric) + if (entry.IsOpenGeneric) continue; + sb.Append(" r.").Append(registerMethod).Append('<') + .Append(entry.RequestTypeFullyQualified).Append(", ") + .Append(entry.HandlerTypeFullyQualified) + .AppendLine(">();"); + } + + // Open-generic handlers need EnsureHandlerIsRegistered, which only exists on the DI + // extension's concrete ServiceCollectionSubscriberRegistry. Emit the cast only when at + // least one open generic is present, so the common case stays interface-only. + if (hasOpenGeneric) + { + sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); + foreach (var entry in entries) { + if (!entry.IsOpenGeneric) continue; sb.Append(" registry.EnsureHandlerIsRegistered(typeof(") .Append(entry.HandlerTypeFullyQualified) .AppendLine("));"); } - else - { - sb.Append(" registry.Add(typeof(") - .Append(entry.RequestTypeFullyQualified) - .Append("), typeof(") - .Append(entry.HandlerTypeFullyQualified) - .AppendLine("));"); - } } sb.AppendLine(" });"); @@ -172,7 +186,11 @@ private static void WriteTransforms( if (transforms.Count == 0) return; - sb.Append(" ").Append(paramName).AppendLine(".Transforms(r =>"); + // Transforms is an extension method on IBrighterBuilder; call statically so the + // generated source doesn't depend on a `using` being added by the consumer. + sb.Append(" global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderExtensions.Transforms(") + .Append(paramName) + .AppendLine(", r =>"); sb.AppendLine(" {"); foreach (var transform in transforms) diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index ef9eaf5849..c7561597ab 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -67,34 +67,44 @@ public static MethodCandidate ReadMethod(GeneratorAttributeSyntaxContext ctx, Ca /// .CreateSyntaxProvider: inspects a single class declaration and projects any /// Brighter-related interface implementations to value-equatable records. /// - public static EquatableArray ReadClass(GeneratorSyntaxContext ctx, CancellationToken cancellationToken) + public static DiscoveryBatch ReadClass(GeneratorSyntaxContext ctx, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (ctx.Node is not ClassDeclarationSyntax cls) - return EquatableArray.Empty; + return DiscoveryBatch.Empty; if (ctx.SemanticModel.GetDeclaredSymbol(cls, cancellationToken) is not INamedTypeSymbol type) - return EquatableArray.Empty; + return DiscoveryBatch.Empty; if (!IsClassifiable(type)) - return EquatableArray.Empty; + return DiscoveryBatch.Empty; // Only emit from the "primary" partial declaration so partial classes don't get - // discovered N times. Roslyn orders DeclaringSyntaxReferences deterministically. + // discovered N times. Order explicitly so the choice is self-evidently stable. if (!IsPrimaryDeclaration(type, cls)) - return EquatableArray.Empty; + return DiscoveryBatch.Empty; var markers = MarkerSymbols.Resolve(ctx.SemanticModel.Compilation); if (!markers.IsValid) - return EquatableArray.Empty; - - if (HasExcludeAttribute(type, ctx.SemanticModel.Compilation)) - return EquatableArray.Empty; - - return new EquatableArray(ClassifyEntries(type, markers)); + return DiscoveryBatch.Empty; + + if (markers.ExcludeAttribute is not null && HasAttribute(type, markers.ExcludeAttribute)) + return DiscoveryBatch.Empty; + + var entries = new List(); + var diagnostics = new List(); + ClassifyEntries(type, markers, entries, diagnostics); + if (entries.Count == 0 && diagnostics.Count == 0) + return DiscoveryBatch.Empty; + return new DiscoveryBatch( + new EquatableArray(entries), + new EquatableArray(diagnostics)); } + private static bool HasAttribute(INamedTypeSymbol type, INamedTypeSymbol attribute) => + type.GetAttributes().Any(a => Same(a.AttributeClass, attribute)); + private static DiagnosticInfo? ValidateMethod(IMethodSymbol method, MarkerSymbols markers) { var location = LocationInfo.From(method.Locations.FirstOrDefault()); @@ -134,29 +144,48 @@ private static MethodTarget ProjectMethod(IMethodSymbol method) HintName: BuildHintName(containingType, method.Name)); } - private static IEnumerable ClassifyEntries(INamedTypeSymbol type, MarkerSymbols markers) + private static void ClassifyEntries( + INamedTypeSymbol type, + MarkerSymbols markers, + List entries, + List diagnostics) { var seenTransform = false; + var unsupportedGenericMapperOrTransform = false; + foreach (var iface in type.AllInterfaces) { - var entry = TryClassifyInterface(type, iface, markers, ref seenTransform); + var entry = TryClassifyInterface(type, iface, markers, ref seenTransform, ref unsupportedGenericMapperOrTransform); if (entry is not null) - yield return entry; + entries.Add(entry); } if (seenTransform && !type.IsGenericType) - yield return new DiscoveredEntry(DiscoveredKind.Transform, string.Empty, FullyQualified(type), IsOpenGeneric: false); + entries.Add(new DiscoveredEntry(DiscoveredKind.Transform, string.Empty, FullyQualified(type), IsOpenGeneric: false)); + + if (unsupportedGenericMapperOrTransform) + { + var location = LocationInfo.From(type.Locations.FirstOrDefault()); + diagnostics.Add(new DiagnosticInfo( + Diagnostics.GenericMapperOrTransformIgnored.Id, + location, + FullyQualified(type))); + } } private static DiscoveredEntry? TryClassifyInterface( INamedTypeSymbol type, INamedTypeSymbol iface, MarkerSymbols markers, - ref bool seenTransform) + ref bool seenTransform, + ref bool unsupportedGenericMapperOrTransform) { if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync)) { - seenTransform = true; + if (type.IsGenericType) + unsupportedGenericMapperOrTransform = true; + else + seenTransform = true; return null; } @@ -170,10 +199,16 @@ private static IEnumerable ClassifyEntries(INamedTypeSymbol typ return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType); if (Same(def, markers.HandleRequestsAsync)) return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType); - if (Same(def, markers.MessageMapper) && !type.IsGenericType) + if (Same(def, markers.MessageMapper)) + { + if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; } return new DiscoveredEntry(DiscoveredKind.Mapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); - if (Same(def, markers.MessageMapperAsync) && !type.IsGenericType) + } + if (Same(def, markers.MessageMapperAsync)) + { + if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; } return new DiscoveredEntry(DiscoveredKind.AsyncMapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); + } return null; } @@ -199,18 +234,14 @@ private static bool IsPrimaryDeclaration(INamedTypeSymbol type, ClassDeclaration var refs = type.DeclaringSyntaxReferences; if (refs.Length <= 1) return true; - var primary = refs[0]; + // Deterministic primary pick that doesn't rely on undocumented Roslyn ordering. + var primary = refs + .OrderBy(r => r.SyntaxTree.FilePath, System.StringComparer.Ordinal) + .ThenBy(r => r.Span.Start) + .First(); return primary.SyntaxTree == cls.SyntaxTree && primary.Span == cls.Span; } - private static bool HasExcludeAttribute(INamedTypeSymbol type, Compilation compilation) - { - var attr = compilation.GetTypeByMetadataName("Paramore.Brighter.ExcludeFromBrighterRegistrationAttribute"); - if (attr is null) - return false; - return type.GetAttributes().Any(a => Same(a.AttributeClass, attr)); - } - private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) { for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) @@ -227,7 +258,22 @@ private static string BuildHintName(INamedTypeSymbol containingType, string meth var sanitized = new StringBuilder(raw.Length); foreach (var ch in raw) sanitized.Append(char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_'); - return $"{sanitized}__{methodName}.g.cs"; + // Append a short stable hash of the original display string so that types differing only + // in non-identifier characters (e.g. "Foo.Bar" vs "Foo_Bar") don't collide. + return $"{sanitized}_{Fnv1aHex(raw)}__{methodName}.g.cs"; + } + + private static string Fnv1aHex(string text) + { + const uint offset = 2166136261; + const uint prime = 16777619; + var hash = offset; + foreach (var ch in text) + { + hash ^= ch; + hash *= prime; + } + return hash.ToString("x8"); } private static string FullyQualified(ITypeSymbol type) => diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs index 2aa808e618..f57ac8819a 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs @@ -67,14 +67,17 @@ public void PropertyTrue_GeneratesBrighterAssemblyRegistrations() { var result = Run(UserSource, "true"); - var generated = result.Results.Single().GeneratedSources + var single = result.Results.Single(); + Assert.Empty(single.Diagnostics); + + var generated = single.GeneratedSources .Single(g => g.HintName == "BrighterAssemblyRegistrations__AddFromThisAssembly.g.cs") .SourceText.ToString(); Assert.Contains("namespace Paramore.Brighter.Extensions.DependencyInjection", generated); Assert.Contains("internal static class BrighterAssemblyRegistrations", generated); - Assert.Contains("public static global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly", generated); - Assert.Contains("registry.Add(typeof(global::App.GreetingCommand), typeof(global::App.GreetingHandler));", generated); + Assert.Contains("internal static global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly", generated); + Assert.Contains("r.Register();", generated); Assert.DoesNotContain("partial", generated); } diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs index 4570ba2d86..1e06774de9 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs @@ -75,7 +75,7 @@ public static partial class Registrations test.TestState.GeneratedSources.Add(AttributeFile()); test.TestState.GeneratedSources.Add(( typeof(BrighterRegistrationsGenerator), - "App_Registrations__AddFromThisAssembly.g.cs", + "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", """ // #nullable enable @@ -88,8 +88,7 @@ public static partial class Registrations { builder.Handlers(r => { - var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r; - registry.Add(typeof(global::App.GreetingCommand), typeof(global::App.GreetingHandler)); + r.Register(); }); return builder; } @@ -133,7 +132,7 @@ public static partial class Registrations test.TestState.GeneratedSources.Add(AttributeFile()); test.TestState.GeneratedSources.Add(( typeof(BrighterRegistrationsGenerator), - "App_Registrations__AddFromThisAssembly.g.cs", + "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", """ // #nullable enable @@ -154,6 +153,397 @@ public static partial class Registrations await test.RunAsync(); } + [Fact] + public async Task AsyncHandler_GeneratesRegisterAsync() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public class GreetingHandler : RequestHandlerAsync + { + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(( + typeof(BrighterRegistrationsGenerator), + "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", + """ + // + #nullable enable + + namespace App + { + public static partial class Registrations + { + public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + { + builder.AsyncHandlers(r => + { + r.RegisterAsync(); + }); + return builder; + } + } + } + + """)); + + await test.RunAsync(); + } + + [Fact] + public async Task MapperAndAsyncMapperAndTransform_AreAllDiscovered() + { + const string userCode = """ + using System; + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingEvent : Event { public GreetingEvent() : base(System.Guid.NewGuid()) { } } + + public class GreetingMapper : IAmAMessageMapper + { + public IRequestContext? Context { get; set; } + public Message MapToMessage(GreetingEvent request, Publication publication) => new(); + public GreetingEvent MapToRequest(Message message) => new(); + } + + public class GreetingMapperAsync : IAmAMessageMapperAsync + { + public IRequestContext? Context { get; set; } + public System.Threading.Tasks.Task MapToMessageAsync(GreetingEvent request, Publication publication, System.Threading.CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(new Message()); + public System.Threading.Tasks.Task MapToRequestAsync(Message message, System.Threading.CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(new GreetingEvent()); + } + + public class NoOp : IAmAMessageTransform + { + public IRequestContext? Context { get; set; } + public void InitializeWrapFromAttributeParams(params object?[] init) { } + public void InitializeUnwrapFromAttributeParams(params object?[] init) { } + public Message Wrap(Message m, Publication p) => m; + public Message Unwrap(Message m) => m; + public void Dispose() { } + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(( + typeof(BrighterRegistrationsGenerator), + "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", + """ + // + #nullable enable + + namespace App + { + public static partial class Registrations + { + public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + { + builder.MapperRegistry(r => + { + r.Add(typeof(global::App.GreetingEvent), typeof(global::App.GreetingMapper)); + r.AddAsync(typeof(global::App.GreetingEvent), typeof(global::App.GreetingMapperAsync)); + }); + global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderExtensions.Transforms(builder, r => + { + r.Add(typeof(global::App.NoOp)); + }); + return builder; + } + } + } + + """)); + + await test.RunAsync(); + } + + [Fact] + public async Task PartialHandler_IsRegisteredOnce() + { + const string partA = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public partial class SplitHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + const string partB = """ + namespace App; + + public partial class SplitHandler + { + public void Helper() { } + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(partA); + test.TestState.Sources.Add(partB); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(( + typeof(BrighterRegistrationsGenerator), + "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", + """ + // + #nullable enable + + namespace App + { + public static partial class Registrations + { + public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + { + builder.Handlers(r => + { + r.Register(); + }); + return builder; + } + } + } + + """)); + + await test.RunAsync(); + } + + [Fact] + public async Task AbstractAndPrivateNestedHandlers_AreFilteredOut() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public abstract class AbstractHandler : RequestHandler + { + } + + internal class Outer + { + private class PrivateNested : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(( + typeof(BrighterRegistrationsGenerator), + "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", + """ + // + #nullable enable + + namespace App + { + public static partial class Registrations + { + public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + { + return builder; + } + } + } + + """)); + + await test.RunAsync(); + } + + [Fact] + public async Task GenericMapper_ReportsBRGEN005() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingEvent : Event { public GreetingEvent() : base(System.Guid.NewGuid()) { } } + + public class OpenMapper : IAmAMessageMapper + { + public IRequestContext? Context { get; set; } + public Message MapToMessage(GreetingEvent request, Publication publication) => new(); + public GreetingEvent MapToRequest(Message message) => new(); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(( + typeof(BrighterRegistrationsGenerator), + "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", + """ + // + #nullable enable + + namespace App + { + public static partial class Registrations + { + public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + { + return builder; + } + } + } + + """)); + test.TestState.ExpectedDiagnostics.Add( + DiagnosticResult.CompilerWarning("BRGEN005").WithSpan(8, 14, 8, 24).WithArguments("global::App.OpenMapper")); + + await test.RunAsync(); + } + + [Fact] + public async Task NonStaticMethod_ReportsBRGEN002() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public partial class Registrations + { + [BrighterRegistrations] + public partial IBrighterBuilder AddFromThisAssembly(IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.ExpectedDiagnostics.Add( + DiagnosticResult.CompilerError("BRGEN002").WithSpan(9, 37, 9, 56).WithArguments("AddFromThisAssembly")); + // The C# compiler will also complain that the partial method has no implementation; + // we don't care about its exact diagnostics here. + test.CompilerDiagnostics = CompilerDiagnostics.None; + + await test.RunAsync(); + } + + [Fact] + public async Task WrongReturnType_ReportsBRGEN003() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial void AddFromThisAssembly(IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.ExpectedDiagnostics.Add( + DiagnosticResult.CompilerError("BRGEN003").WithSpan(9, 32, 9, 51).WithArguments("AddFromThisAssembly")); + test.CompilerDiagnostics = CompilerDiagnostics.None; + + await test.RunAsync(); + } + + [Fact] + public async Task WrongSignature_ReportsBRGEN004() + { + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(IBrighterBuilder builder, int extra); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.ExpectedDiagnostics.Add( + DiagnosticResult.CompilerError("BRGEN004").WithSpan(9, 44, 9, 63).WithArguments("AddFromThisAssembly")); + test.CompilerDiagnostics = CompilerDiagnostics.None; + + await test.RunAsync(); + } + [Fact] public async Task NonPartialMethod_ReportsBRGEN001() { diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs index 51527f0bfe..07ad026cdd 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs @@ -44,7 +44,7 @@ public void EmptyModel_EmitsScaffoldAndReturnsBuilder() } [Fact] - public void ClosedHandler_EmitsRegistryAddWithFullyQualifiedTypes() + public void ClosedHandler_UsesGenericRegisterAndOmitsImplementationCast() { var model = EmptyModel(handlers: new EquatableArray(new[] { @@ -54,8 +54,8 @@ public void ClosedHandler_EmitsRegistryAddWithFullyQualifiedTypes() var output = RegistrationWriter.Write(model); Assert.Contains("builder.Handlers(r =>", output); - Assert.Contains("var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;", output); - Assert.Contains("registry.Add(typeof(global::MyApp.GreetingCommand), typeof(global::MyApp.GreetingCommandHandler));", output); + Assert.Contains("r.Register();", output); + Assert.DoesNotContain("ServiceCollectionSubscriberRegistry", output); } [Fact] @@ -68,8 +68,26 @@ public void OpenGenericHandler_EmitsEnsureHandlerIsRegistered() var output = RegistrationWriter.Write(model); + Assert.Contains("var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;", output); + Assert.Contains("registry.EnsureHandlerIsRegistered(typeof(global::MyApp.PolicyHandler<>));", output); + } + + [Fact] + public void MixedClosedAndOpenHandlers_EmitsCastOnlyOnce() + { + var model = EmptyModel(handlers: new EquatableArray(new[] + { + new HandlerEntry("global::MyApp.GreetingCommand", "global::MyApp.GreetingCommandHandler", IsOpenGeneric: false), + new HandlerEntry(string.Empty, "global::MyApp.PolicyHandler<>", IsOpenGeneric: true), + })); + + var output = RegistrationWriter.Write(model); + + Assert.Contains("r.Register();", output); Assert.Contains("registry.EnsureHandlerIsRegistered(typeof(global::MyApp.PolicyHandler<>));", output); - Assert.DoesNotContain("registry.Add(typeof(", output); + // Cast appears exactly once. + var castOccurrences = output.Split(new[] { "ServiceCollectionSubscriberRegistry)r" }, System.StringSplitOptions.None).Length - 1; + Assert.Equal(1, castOccurrences); } [Fact] @@ -116,7 +134,7 @@ public void Transforms_EmitsTransformsCallback() var output = RegistrationWriter.Write(model); - Assert.Contains("builder.Transforms(r =>", output); + Assert.Contains("global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderExtensions.Transforms(builder, r =>", output); Assert.Contains("r.Add(typeof(global::MyApp.NoOpTransformer));", output); } From b69455040f357913e016d20d91167e0c60ec85a1 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Thu, 4 Jun 2026 19:07:02 +0100 Subject: [PATCH 08/20] docs: add ADR 0062 for source-generated handler registration Records the compile-time registration generator as an opt-in, additive alternative to assembly scanning. Ties the decision to #4159 (the unloaded-assembly blind spot it structurally fixes for first-party types) and #4160 / ADR 0061 (the runtime symptom it reduces reliance on). Co-Authored-By: Claude Opus 4.8 --- ...2-source-generated-handler-registration.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/adr/0062-source-generated-handler-registration.md diff --git a/docs/adr/0062-source-generated-handler-registration.md b/docs/adr/0062-source-generated-handler-registration.md new file mode 100644 index 0000000000..08d812e1f6 --- /dev/null +++ b/docs/adr/0062-source-generated-handler-registration.md @@ -0,0 +1,220 @@ +# 62. Source-Generated Handler, Mapper and Transform Registration + +Date: 2026-06-04 + +## Status + +Proposed + +## Context + +**Scope**: This ADR covers an opt-in, compile-time alternative to runtime assembly scanning for +registering Brighter handlers, message mappers and message transforms. It does **not** remove or +change assembly scanning; the two mechanisms are additive. + +### The Problem + +Brighter discovers handlers, mappers and transforms at startup by scanning assemblies — either the +assemblies the host happens to have loaded, or the explicit list passed to +`AutoFromAssemblies([...])`. This has well-known failure modes: + +1. **Unloaded assemblies are invisible.** Scanning only sees *loaded* assemblies. An assembly that + has not yet been loaded when scanning runs contributes no registrations, so the handlers, + mappers or transforms it contains are silently absent. This is the subject of issue + [#4159](https://github.com/BrighterCommand/Brighter/issues/4159): for example, using a + `JustSayingMessageMapper` without also ensuring its assembly (and its dependency + `JustSayingCompressionTransform`) is scanned leaves the pipeline incompletely registered. + +2. **The failure is silent and only surfaces at runtime — destructively.** A missing mapper or + transform throws `MessageMappingException` while unwrapping an incoming message. Until issue + [#4160](https://github.com/BrighterCommand/Brighter/issues/4160) (fixed in + [ADR 0061](0061-reject_mapping_errors.md)), the message pump silently acknowledged — i.e. + deleted — such messages. Even with the reject-path fix, the operator still gets a message routed + to the Invalid Message Queue / DLQ rather than a correctly processed one. The registration was + never wrong in the code; it was lost at scan time. + +3. **No compile-time feedback.** Because discovery is reflective and deferred to startup, the + compiler cannot tell the developer that a handler will not be registered. Mistakes are found by + running the application, not by building it. + +4. **Reflection has a cost.** Assembly scanning at startup is incompatible with trimming / Native + AOT and adds startup latency proportional to the assembly surface scanned. + +### Requirements Context + +This is not derived from a `specs/` requirement; it is a standalone architectural decision recorded +retroactively to capture work already implemented on the +`slang25/source-gen-auto-assemblies` branch. Brighter already treats source generation as an +accepted technique for cross-cutting concerns (see [ADR 0026](0026-use-source-generated-logging.md), +source-generated logging) and ships Roslyn analyzers for pipeline validation +([ADR 0037](0037-provide-roslyn-analyzers-for-brighter.md), +[ADR 0054](0054-roslyn-analyzer-extensions-for-pipeline-validation.md)), so a generator that emits +registration code is consistent with the existing direction. + +### Constraints + +- **Must not break existing users.** Assembly scanning and `AutoFromAssemblies` must keep working + unchanged; source generation is opt-in and additive. +- **Must register through the existing public surface.** Generated code must go through the same + registries the framework already uses (`IBrighterBuilder.Handlers`, + `IBrighterBuilder.MapperRegistry`, and transform registration) so that downstream behaviour — + including `ValidatePipelines` / `DescribePipelines` — is identical regardless of how a type was + registered. +- **Must not introduce a binary-breaking change** to `IBrighterBuilder` for downstream + implementers. +- **Must be incremental.** The generator must participate correctly in Roslyn's incremental + pipeline (no semantic-model objects escaping transforms; value-equatable models only) so it does + not regress IDE/build performance. + +## Decision + +We add a new analyzer-only package, `Paramore.Brighter.SourceGenerators`, containing an +`IIncrementalGenerator` (`BrighterRegistrationsGenerator`) that discovers Brighter types **in the +current compilation** and emits explicit registration code. Registration becomes a compile-time +artefact of the project that owns the types, not a runtime reflection pass over loaded assemblies. + +### Roles and responsibilities + +The implementation is split so each type has a single, cohesive responsibility, with no Roslyn +semantic-model object crossing a pipeline boundary: + +| Role (stereotype) | Type | Responsibility | +|---|---|---| +| Interfacer / information holder | `MarkerSymbols` | *Knowing* the Brighter framework interface symbols (`IHandleRequests<>`, `IHandleRequestsAsync<>`, `IAmAMessageMapper<>`, `IAmAMessageMapperAsync<>`, `IAmAMessageTransform`, `IAmAMessageTransformAsync`) in a given compilation, and whether Brighter is referenced at all. | +| Service provider | `SemanticModelReader` | *Deciding* how a method or class is classified — projecting Roslyn symbols into Roslyn-free, value-equatable records (`MethodCandidate`, `DiscoveredEntry`, `DiagnosticInfo`). | +| Information holder | `Model/*` (`RegistrationModel`, `DiscoveredEntry`, `EquatableArray`, …) | *Knowing* the discovered registrations as pure, equatable data the incremental cache can compare by value. | +| Service provider | `RegistrationWriter` | *Doing* the text generation — turning a `RegistrationModel` into C# source. Holds no Roslyn references, so it is unit-testable without a `Compilation`. | +| Coordinator | `BrighterRegistrationsGenerator` | *Coordinating* the incremental pipeline: wiring the syntax providers, combining streams, and handing models to the writer. | + +### What is generated + +The developer marks a `static partial` method that returns `IBrighterBuilder` and takes a single +`IBrighterBuilder`, with `[BrighterRegistrations]`. The generator implements that partial method, +emitting calls that register every discovered type: + +- **Handlers** via `builder.Handlers(...)` / `builder.AsyncHandlers(...)`. Closed generics use the + strongly-typed `Register()`; open-generic handlers use + `EnsureHandlerIsRegistered(typeof(...))`. +- **Mappers** via `builder.MapperRegistry(...)` (`Add` / `AddAsync`). +- **Transforms** via a new off-interface `BrighterBuilderExtensions.Transforms(...)` extension + (added so transform registration is symmetric with handlers/mappers **without** a binary-breaking + addition to `IBrighterBuilder`). + +`[ExcludeFromBrighterRegistration]` opts a single type out. Generic mappers/transforms cannot be +registered as-is and produce diagnostic `BRGEN005` (warning) rather than being silently dropped. +Malformed registration methods produce `BRGEN001`–`BRGEN004` (errors), giving the compile-time +feedback that scanning never could. + +### Per-package auto-registration (the referenced-assembly story) + +A source generator only sees the syntax of the **current** compilation; types compiled into a +*referenced* package have no syntax tree in the consumer and are therefore invisible to the +consumer's generator. To let a library contribute its own registrations without the consumer +re-scanning it, the package ships a `build/` props file that sets the +`BrighterAutoRegistration` MSBuild property. When true, the generator additionally synthesises an +`internal static BrighterAssemblyRegistrations.AddFromThisAssembly(this IBrighterBuilder)` +extension covering *that compilation's* types. Because the prop is in `build/` (not +`buildTransitive/`), it only applies to a **direct** `PackageReference`, so a library generates its +own registrations at its own compile time and the consumer calls `AddFromThisAssembly()` — rather +than the consumer trying to load and scan the library at runtime. + +## Consequences + +### Positive + +- **Eliminates the #4159 root cause for first-party types.** Registration is derived from the + compilation, so the "assembly not loaded at scan time" failure mode cannot occur for types in the + project (or in a package that adopts this generator). Handlers, mappers **and** transforms are all + covered — including the transform case (`JustSayingCompressionTransform`) that #4159 calls out. +- **Failures move from runtime to build time.** Misconfigured registration methods fail compilation + (`BRGEN001`–`BRGEN004`); unsupported generic mappers/transforms warn (`BRGEN005`). +- **Reduces the destructive variant of `MessageMappingException`.** Reliable compile-time + registration means fewer messages reach the [ADR 0061](0061-reject_mapping_errors.md) reject path + because a mapper/transform was missing. +- **Trimming / AOT friendly and lower startup cost** for projects that rely on generation instead + of scanning. +- **Behaviourally identical downstream.** Generated registrations flow through the same registries, + so `ValidatePipelines` / `DescribePipelines` behave the same. + +### Negative + +- **Referenced-package types are only covered if the package adopts the generator.** A third-party + package that merely ships compiled mappers/transforms (and does not use this generator + props) is + still invisible to the consumer's generator. Those users must register such types explicitly or + continue to use `AutoFromAssemblies`. Source generation does not make scanning obsolete. +- **Two registration paths to understand.** Scanning and source generation now coexist; teams must + know which they are using (and that they are additive). +- **Generic mappers/transforms are unsupported** (by design) and require a closed type, a + non-generic wrapper, or an explicit exclude. + +### Risks and Mitigations + +**Risk**: Users assume source generation replaces scanning and stop adding required assemblies, +silently losing third-party package registrations. +- **Mitigation**: Document the referenced-package boundary explicitly; keep `AutoFromAssemblies` + fully supported as an additive fallback; consider a future analyzer warning when a referenced + Brighter-using assembly is neither generated nor scanned. + +**Risk**: `ValidatePipelines` evolves (per #4159) to warn that required assemblies are missing from +`AutoFromAssemblies`, producing false positives for apps that register via source generation and +have no `AutoFromAssemblies` list. +- **Mitigation**: Any such validation must treat source-generated registrations as satisfying the + requirement. Because generated code registers through the same registries, validation that checks + *what is registered* (rather than *which assemblies were listed*) remains correct. + +**Risk**: A semantic-model object escaping a transform would break incremental caching and regress +IDE performance. +- **Mitigation**: Every value crossing the pipeline is a value-equatable record; this is covered by + the incremental-caching tests. + +## Alternatives Considered + +### Alternative 1: Keep runtime assembly scanning only + +Continue to rely on reflection over loaded / listed assemblies. + +**Rejected because**: +- Does not address #4159 (unloaded assemblies) or give compile-time feedback. +- Incompatible with trimming / Native AOT. + +### Alternative 2: Generator scans referenced assembly *metadata* (symbols), not just syntax + +Have the generator enumerate `IAssemblySymbol` references and classify their types, so package types +are discovered without the package adopting the generator. + +**Rejected because**: +- Walking referenced metadata on every keystroke is the kind of work incremental generators are + designed to avoid; it would hurt IDE performance and caching. +- It re-creates reflective ambiguity (which referenced assemblies are "ours"?) at build time. +- The per-package `AddFromThisAssembly` approach gives each library ownership of its own + registrations at its own compile time, which is cleaner and composes. + +### Alternative 3: Make registration a runtime source-generated reflection cache + +Generate a static map but still resolve it reflectively at startup. + +**Rejected because**: +- Retains a startup reflection step and trimming/AOT hazards for no real gain over emitting direct + registration calls. + +### Alternative 4: Add `Transforms(...)` to `IBrighterBuilder` + +Put transform registration directly on the interface for symmetry with `Handlers` / `MapperRegistry`. + +**Rejected because**: +- It is a binary-breaking change for downstream implementers of `IBrighterBuilder`. An off-interface + extension method (`BrighterBuilderExtensions.Transforms`) achieves the same ergonomics without + breaking the contract. + +## References + +- Related issues: + - [#4159: ValidatePipeline should check all required assemblies will be scanned](https://github.com/BrighterCommand/Brighter/issues/4159) — the root cause this generator structurally addresses for first-party types. + - [#4160: Message pump silently Acks on MessageMappingException](https://github.com/BrighterCommand/Brighter/issues/4160) — the downstream symptom of a missing registration. +- Related ADRs: + - [ADR 0061: Reject Mapping Errors](0061-reject_mapping_errors.md) — routes failed mappings to IMQ/DLQ; complementary runtime safety net that source generation reduces reliance on. + - [ADR 0026: Use Source-Generated Logging](0026-use-source-generated-logging.md) — precedent for source generation in the codebase. + - [ADR 0037: Provide Roslyn Analyzers for Brighter](0037-provide-roslyn-analyzers-for-brighter.md) and [ADR 0054: Roslyn Analyzer Extensions for Pipeline Validation](0054-roslyn-analyzer-extensions-for-pipeline-validation.md) — existing compile-time tooling direction. + - [ADR 0053: Pipeline Validation at Startup](0053-pipeline-validation-at-startup.md) — the `ValidatePipelines` mechanism that must remain correct under source-generated registration. +- External references: + - [Incremental Generators design](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md) From c7b1c4a9b7cb45e9106e278ab58be55fd7e310ab Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Thu, 4 Jun 2026 19:48:12 +0100 Subject: [PATCH 09/20] refactor: emit registrations via IndentedTextWriter-based CodeWriter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled whitespace constants in RegistrationWriter with a CodeWriter that subclasses IndentedTextWriter and manages brace blocks (StartBlock/EndBlock), modelled on the ASP.NET Core source generators' CodeWriter. Pure structural change — generated output is byte-identical, as verified by the exact-snapshot generator tests. Co-Authored-By: Claude Opus 4.8 --- .../CodeWriter.cs | 57 ++++++++ .../RegistrationWriter.cs | 137 ++++++++---------- 2 files changed, 121 insertions(+), 73 deletions(-) create mode 100644 src/Paramore.Brighter.SourceGenerators/CodeWriter.cs diff --git a/src/Paramore.Brighter.SourceGenerators/CodeWriter.cs b/src/Paramore.Brighter.SourceGenerators/CodeWriter.cs new file mode 100644 index 0000000000..a58917c149 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/CodeWriter.cs @@ -0,0 +1,57 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.CodeDom.Compiler; +using System.IO; + +namespace Paramore.Brighter.SourceGenerators; + +/// +/// A thin wrapper that manages brace blocks so emit code can +/// express structure ( / ) instead of hand-rolling +/// indentation whitespace. Modelled on the CodeWriter used by the ASP.NET Core source generators. +/// +internal sealed class CodeWriter : IndentedTextWriter +{ + public CodeWriter(StringWriter writer, int baseIndent = 0) : base(writer) + { + Indent = baseIndent; + } + + /// Write an opening brace and increase the indent for the block body. + public void StartBlock() + { + WriteLine("{"); + Indent++; + } + + /// + /// Decrease the indent and write a closing brace, optionally followed by + /// — for example ");" to close a method-call lambda block. + /// + public void EndBlock(string suffix = "") + { + Indent--; + WriteLine("}" + suffix); + } +} diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index 72d03d0c60..a6f1c2d1e7 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -21,6 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #endregion +using System.IO; using System.Text; using Paramore.Brighter.SourceGenerators.Model; @@ -29,43 +30,62 @@ namespace Paramore.Brighter.SourceGenerators; /// /// Pure function that turns a into the C# source for the /// partial method implementation. Holds no Roslyn references, so it can be unit-tested -/// without constructing a Compilation. +/// without constructing a Compilation. Indentation is managed structurally by +/// rather than by embedding whitespace in the emitted strings. /// public static class RegistrationWriter { public static string Write(RegistrationModel model) { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine(); + var buffer = new StringWriter(); + using var code = new CodeWriter(buffer); + + code.WriteLine("// "); + code.WriteLine("#nullable enable"); + code.WriteLineNoTabs(string.Empty); var hasNamespace = !string.IsNullOrEmpty(model.Namespace); if (hasNamespace) { - sb.Append("namespace ").Append(model.Namespace).AppendLine(); - sb.AppendLine("{"); + code.WriteLine($"namespace {model.Namespace}"); + code.StartBlock(); } + else + { + // Preserve the historical layout: the registration class is indented one level even + // when there is no namespace wrapper to open a block. + code.Indent++; + } + + WriteContainingType(code, model); - WriteContainingTypeOpen(sb, model); - WriteMethodSignature(sb, model); + if (hasNamespace) + code.EndBlock(); - WriteHandlers(sb, model.ParameterName, model.Handlers, isAsync: false); - WriteHandlers(sb, model.ParameterName, model.AsyncHandlers, isAsync: true); - WriteMappers(sb, model.ParameterName, model.Mappers, model.AsyncMappers); - WriteTransforms(sb, model.ParameterName, model.Transforms); + code.Flush(); + return buffer.ToString(); + } - sb.Append(" return ").Append(model.ParameterName).AppendLine(";"); - sb.AppendLine(" }"); - sb.AppendLine(" }"); + private static void WriteContainingType(CodeWriter code, RegistrationModel model) + { + code.WriteLine(ContainingTypeDeclaration(model)); + code.StartBlock(); - if (hasNamespace) - sb.AppendLine("}"); + code.WriteLine(MethodSignature(model)); + code.StartBlock(); - return sb.ToString(); + WriteHandlers(code, model.ParameterName, model.Handlers, isAsync: false); + WriteHandlers(code, model.ParameterName, model.AsyncHandlers, isAsync: true); + WriteMappers(code, model.ParameterName, model.Mappers, model.AsyncMappers); + WriteTransforms(code, model.ParameterName, model.Transforms); + + code.WriteLine($"return {model.ParameterName};"); + + code.EndBlock(); // method + code.EndBlock(); // containing type } - private static void WriteContainingTypeOpen(StringBuilder sb, RegistrationModel model) + private static string ContainingTypeDeclaration(RegistrationModel model) { var typeKeyword = (model.ContainingTypeIsStatic, model.IsPartial) switch { @@ -74,30 +94,24 @@ private static void WriteContainingTypeOpen(StringBuilder sb, RegistrationModel (false, true) => "partial class", (false, false) => "class", }; - sb.Append(" ") - .Append(model.ContainingTypeAccessibility) - .Append(' ') - .Append(typeKeyword) - .Append(' ') - .AppendLine(model.ContainingTypeName); - sb.AppendLine(" {"); + return $"{model.ContainingTypeAccessibility} {typeKeyword} {model.ContainingTypeName}"; } - private static void WriteMethodSignature(StringBuilder sb, RegistrationModel model) + private static string MethodSignature(RegistrationModel model) { - sb.Append(" ").Append(model.MethodAccessibility).Append(" static "); + var sb = new StringBuilder(); + sb.Append(model.MethodAccessibility).Append(" static "); if (model.IsPartial) sb.Append("partial "); sb.Append(model.ReturnTypeFullyQualified).Append(' ').Append(model.MethodName).Append('('); if (model.IsExtensionMethod) sb.Append("this "); - sb.Append(model.ParameterTypeFullyQualified).Append(' ').Append(model.ParameterName); - sb.AppendLine(")"); - sb.AppendLine(" {"); + sb.Append(model.ParameterTypeFullyQualified).Append(' ').Append(model.ParameterName).Append(')'); + return sb.ToString(); } private static void WriteHandlers( - StringBuilder sb, + CodeWriter code, string paramName, EquatableArray entries, bool isAsync) @@ -113,18 +127,15 @@ private static void WriteHandlers( if (entry.IsOpenGeneric) { hasOpenGeneric = true; break; } } - sb.Append(" ").Append(paramName).Append('.').Append(callbackMethod).AppendLine("(r =>"); - sb.AppendLine(" {"); + code.WriteLine($"{paramName}.{callbackMethod}(r =>"); + code.StartBlock(); // For closed-generic handlers, use the strongly-typed Register() method // available on the public interface — no implementation cast needed. foreach (var entry in entries) { if (entry.IsOpenGeneric) continue; - sb.Append(" r.").Append(registerMethod).Append('<') - .Append(entry.RequestTypeFullyQualified).Append(", ") - .Append(entry.HandlerTypeFullyQualified) - .AppendLine(">();"); + code.WriteLine($"r.{registerMethod}<{entry.RequestTypeFullyQualified}, {entry.HandlerTypeFullyQualified}>();"); } // Open-generic handlers need EnsureHandlerIsRegistered, which only exists on the DI @@ -132,21 +143,19 @@ private static void WriteHandlers( // least one open generic is present, so the common case stays interface-only. if (hasOpenGeneric) { - sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); + code.WriteLine("var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); foreach (var entry in entries) { if (!entry.IsOpenGeneric) continue; - sb.Append(" registry.EnsureHandlerIsRegistered(typeof(") - .Append(entry.HandlerTypeFullyQualified) - .AppendLine("));"); + code.WriteLine($"registry.EnsureHandlerIsRegistered(typeof({entry.HandlerTypeFullyQualified}));"); } } - sb.AppendLine(" });"); + code.EndBlock(");"); } private static void WriteMappers( - StringBuilder sb, + CodeWriter code, string paramName, EquatableArray sync, EquatableArray async) @@ -154,32 +163,20 @@ private static void WriteMappers( if (sync.Count == 0 && async.Count == 0) return; - sb.Append(" ").Append(paramName).AppendLine(".MapperRegistry(r =>"); - sb.AppendLine(" {"); + code.WriteLine($"{paramName}.MapperRegistry(r =>"); + code.StartBlock(); foreach (var entry in sync) - { - sb.Append(" r.Add(typeof(") - .Append(entry.RequestTypeFullyQualified) - .Append("), typeof(") - .Append(entry.MapperTypeFullyQualified) - .AppendLine("));"); - } + code.WriteLine($"r.Add(typeof({entry.RequestTypeFullyQualified}), typeof({entry.MapperTypeFullyQualified}));"); foreach (var entry in async) - { - sb.Append(" r.AddAsync(typeof(") - .Append(entry.RequestTypeFullyQualified) - .Append("), typeof(") - .Append(entry.MapperTypeFullyQualified) - .AppendLine("));"); - } + code.WriteLine($"r.AddAsync(typeof({entry.RequestTypeFullyQualified}), typeof({entry.MapperTypeFullyQualified}));"); - sb.AppendLine(" });"); + code.EndBlock(");"); } private static void WriteTransforms( - StringBuilder sb, + CodeWriter code, string paramName, EquatableArray transforms) { @@ -188,18 +185,12 @@ private static void WriteTransforms( // Transforms is an extension method on IBrighterBuilder; call statically so the // generated source doesn't depend on a `using` being added by the consumer. - sb.Append(" global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderExtensions.Transforms(") - .Append(paramName) - .AppendLine(", r =>"); - sb.AppendLine(" {"); + code.WriteLine($"global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderExtensions.Transforms({paramName}, r =>"); + code.StartBlock(); foreach (var transform in transforms) - { - sb.Append(" r.Add(typeof(") - .Append(transform) - .AppendLine("));"); - } + code.WriteLine($"r.Add(typeof({transform}));"); - sb.AppendLine(" });"); + code.EndBlock(");"); } } From c5c29e79f6f67a5587538dff3296021b9219cdb8 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Thu, 4 Jun 2026 20:03:09 +0100 Subject: [PATCH 10/20] feat: stamp generated registrations with banner and [GeneratedCode] Add a shared GeneratedSource helper (banner + tool name/version + the [System.CodeDom.Compiler.GeneratedCode] attribute), modelled on the ASP.NET Core source generators. The registration writer now emits the auto-generated banner and stamps [GeneratedCode] on the generated method; the post-init attributes file gets the same banner. Snapshot tests build their expected output from GeneratedSource so they track the production banner and the tool version dynamically. Co-Authored-By: Claude Opus 4.8 --- .../BrighterRegistrationsGenerator.cs | 5 +- .../GeneratedSource.cs | 63 ++++++ .../RegistrationWriter.cs | 3 +- .../BrighterRegistrationsGeneratorTests.cs | 205 +++++------------- .../RegistrationWriterTests.cs | 10 + 5 files changed, 135 insertions(+), 151 deletions(-) create mode 100644 src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index bef75427f3..662c18bc9c 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -61,8 +61,9 @@ public sealed class BrighterRegistrationsGenerator : IIncrementalGenerator private const string AutoClassName = "BrighterAssemblyRegistrations"; private const string AutoMethodName = "AddFromThisAssembly"; - private const string AttributeSource = """ - // + private static readonly string AttributeSource = GeneratedSource.Header + "\n" + AttributeBody; + + private const string AttributeBody = """ #nullable enable namespace Paramore.Brighter { diff --git a/src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs b/src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs new file mode 100644 index 0000000000..aa5186fe60 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs @@ -0,0 +1,63 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Brighter.SourceGenerators; + +/// +/// Shared markers for emitted source: the "this file was generated" banner and the +/// [System.CodeDom.Compiler.GeneratedCode] stamp. Centralised so every generated file is +/// labelled consistently and the tool version is read from a single place. Modelled on the +/// equivalent helpers in the ASP.NET Core source generators. +/// +public static class GeneratedSource +{ + /// The tool name recorded in [GeneratedCode]. + public const string ToolName = "Paramore.Brighter.SourceGenerators"; + + /// The generator assembly version recorded in [GeneratedCode]. + public static string ToolVersion { get; } = + typeof(GeneratedSource).Assembly.GetName().Version?.ToString() ?? "1.0.0.0"; + + /// + /// The auto-generated file banner. Does not include a #nullable directive — each emitter + /// adds its own — so the banner can sit ahead of whatever the file needs next. + /// + public static string Header { get; } = + """ + //------------------------------------------------------------------------------ + // + // This code was generated by the Brighter source generator. + // + // Changes to this file may cause incorrect behavior and will be lost if + // the code is regenerated. + // + //------------------------------------------------------------------------------ + """; + + /// + /// The [System.CodeDom.Compiler.GeneratedCode(...)] attribute, fully qualified so it can + /// be emitted into any namespace without a using directive. + /// + public static string GeneratedCodeAttribute { get; } = + $"[global::System.CodeDom.Compiler.GeneratedCode(\"{ToolName}\", \"{ToolVersion}\")]"; +} diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index a6f1c2d1e7..ffbbaebbee 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -40,7 +40,7 @@ public static string Write(RegistrationModel model) var buffer = new StringWriter(); using var code = new CodeWriter(buffer); - code.WriteLine("// "); + code.WriteLine(GeneratedSource.Header); code.WriteLine("#nullable enable"); code.WriteLineNoTabs(string.Empty); @@ -71,6 +71,7 @@ private static void WriteContainingType(CodeWriter code, RegistrationModel model code.WriteLine(ContainingTypeDeclaration(model)); code.StartBlock(); + code.WriteLine(GeneratedSource.GeneratedCodeAttribute); code.WriteLine(MethodSignature(model)); code.StartBlock(); diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs index 1e06774de9..7b1815517b 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs @@ -73,28 +73,11 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(( - typeof(BrighterRegistrationsGenerator), - "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", - """ - // - #nullable enable - - namespace App + test.TestState.GeneratedSources.Add(Registration(""" + builder.Handlers(r => { - public static partial class Registrations - { - public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) - { - builder.Handlers(r => - { - r.Register(); - }); - return builder; - } - } - } - + r.Register(); + }); """)); await test.RunAsync(); @@ -130,25 +113,7 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(( - typeof(BrighterRegistrationsGenerator), - "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", - """ - // - #nullable enable - - namespace App - { - public static partial class Registrations - { - public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) - { - return builder; - } - } - } - - """)); + test.TestState.GeneratedSources.Add(Registration("")); await test.RunAsync(); } @@ -181,28 +146,11 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(( - typeof(BrighterRegistrationsGenerator), - "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", - """ - // - #nullable enable - - namespace App + test.TestState.GeneratedSources.Add(Registration(""" + builder.AsyncHandlers(r => { - public static partial class Registrations - { - public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) - { - builder.AsyncHandlers(r => - { - r.RegisterAsync(); - }); - return builder; - } - } - } - + r.RegisterAsync(); + }); """)); await test.RunAsync(); @@ -254,33 +202,16 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(( - typeof(BrighterRegistrationsGenerator), - "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", - """ - // - #nullable enable - - namespace App + test.TestState.GeneratedSources.Add(Registration(""" + builder.MapperRegistry(r => { - public static partial class Registrations - { - public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) - { - builder.MapperRegistry(r => - { - r.Add(typeof(global::App.GreetingEvent), typeof(global::App.GreetingMapper)); - r.AddAsync(typeof(global::App.GreetingEvent), typeof(global::App.GreetingMapperAsync)); - }); - global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderExtensions.Transforms(builder, r => - { - r.Add(typeof(global::App.NoOp)); - }); - return builder; - } - } - } - + r.Add(typeof(global::App.GreetingEvent), typeof(global::App.GreetingMapper)); + r.AddAsync(typeof(global::App.GreetingEvent), typeof(global::App.GreetingMapperAsync)); + }); + global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderExtensions.Transforms(builder, r => + { + r.Add(typeof(global::App.NoOp)); + }); """)); await test.RunAsync(); @@ -325,28 +256,11 @@ public void Helper() { } test.TestState.Sources.Add(partA); test.TestState.Sources.Add(partB); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(( - typeof(BrighterRegistrationsGenerator), - "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", - """ - // - #nullable enable - - namespace App + test.TestState.GeneratedSources.Add(Registration(""" + builder.Handlers(r => { - public static partial class Registrations - { - public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) - { - builder.Handlers(r => - { - r.Register(); - }); - return builder; - } - } - } - + r.Register(); + }); """)); await test.RunAsync(); @@ -388,25 +302,7 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(( - typeof(BrighterRegistrationsGenerator), - "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", - """ - // - #nullable enable - - namespace App - { - public static partial class Registrations - { - public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) - { - return builder; - } - } - } - - """)); + test.TestState.GeneratedSources.Add(Registration("")); await test.RunAsync(); } @@ -439,25 +335,7 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(( - typeof(BrighterRegistrationsGenerator), - "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs", - """ - // - #nullable enable - - namespace App - { - public static partial class Registrations - { - public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) - { - return builder; - } - } - } - - """)); + test.TestState.GeneratedSources.Add(Registration("")); test.TestState.ExpectedDiagnostics.Add( DiagnosticResult.CompilerWarning("BRGEN005").WithSpan(8, 14, 8, 24).WithArguments("global::App.OpenMapper")); @@ -569,11 +447,42 @@ public static class Registrations await test.RunAsync(); } + private const string RegistrationHint = "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs"; + + /// + /// Build the expected generated registration file from its variable body. The banner and the + /// [GeneratedCode] attribute are sourced from so the tests + /// track the production text (and the tool version) automatically. uses + /// indentation relative to the method body (top-level statements at column 0); each line is + /// shifted to the method's three-level indent. + /// + private static (System.Type, string, string) Registration(string body) => + (typeof(BrighterRegistrationsGenerator), RegistrationHint, ExpectedRegistration(body)); + + private static string ExpectedRegistration(string body) + { + var sb = new System.Text.StringBuilder(); + sb.Append(GeneratedSource.Header).Append('\n'); + sb.Append("#nullable enable\n\n"); + sb.Append("namespace App\n{\n"); + sb.Append(" public static partial class Registrations\n {\n"); + sb.Append(" ").Append(GeneratedSource.GeneratedCodeAttribute).Append('\n'); + sb.Append(" public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder)\n"); + sb.Append(" {\n"); + if (!string.IsNullOrEmpty(body)) + { + foreach (var line in body.Replace("\r\n", "\n").Split('\n')) + sb.Append(line.Length == 0 ? string.Empty : new string(' ', 12) + line).Append('\n'); + } + sb.Append(" return builder;\n"); + sb.Append(" }\n }\n}\n"); + return sb.ToString(); + } + private static (System.Type, string, string) AttributeFile() => ( typeof(BrighterRegistrationsGenerator), "BrighterRegistrationsAttributes.g.cs", - """ - // + GeneratedSource.Header + "\n" + """ #nullable enable namespace Paramore.Brighter { diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs index 07ad026cdd..90552567aa 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs @@ -29,6 +29,16 @@ private static RegistrationModel EmptyModel( Transforms: transforms ?? EquatableArray.Empty, HintName: "MyApp_Registrations__AddFromThisAssembly.g.cs"); + [Fact] + public void EmitsGeneratedBannerAndGeneratedCodeAttribute() + { + var output = RegistrationWriter.Write(EmptyModel()); + + Assert.Contains("// ", output); + Assert.Contains("This code was generated by the Brighter source generator.", output); + Assert.Contains("[global::System.CodeDom.Compiler.GeneratedCode(\"Paramore.Brighter.SourceGenerators\", ", output); + } + [Fact] public void EmptyModel_EmitsScaffoldAndReturnsBuilder() { From 66d4116feb18d889fdb2c171d841e7847bbe39fa Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Thu, 4 Jun 2026 22:04:19 +0100 Subject: [PATCH 11/20] test: assert generator incrementality via tracked pipeline stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name the four pipeline stages with WithTrackingName and rewrite the caching tests to assert the model stages are Cached/Unchanged (never Modified) on an unrelated edit, with the post-Collect stages specifically Cached. This catches the classic non-incremental bug where a pipeline value loses value equality — verified by temporarily breaking EquatableArray equality and watching the tests go red. Co-Authored-By: Claude Opus 4.8 --- .../BrighterRegistrationsGenerator.cs | 17 +- .../TrackingNames.cs | 46 ++++++ .../IncrementalCachingTests.cs | 151 +++++++++--------- 3 files changed, 130 insertions(+), 84 deletions(-) create mode 100644 src/Paramore.Brighter.SourceGenerators/TrackingNames.cs diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index 662c18bc9c..d49a32e3bd 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -97,19 +97,23 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .ForAttributeWithMetadataName( AttributeName, predicate: static (node, _) => node is MethodDeclarationSyntax, - transform: static (ctx, ct) => SemanticModelReader.ReadMethod(ctx, ct)); + transform: static (ctx, ct) => SemanticModelReader.ReadMethod(ctx, ct)) + .WithTrackingName(TrackingNames.MethodCandidates); var discoveryBatches = context.SyntaxProvider .CreateSyntaxProvider( predicate: static (node, _) => node is ClassDeclarationSyntax cls && cls.BaseList is not null, transform: static (ctx, ct) => SemanticModelReader.ReadClass(ctx, ct)) .Where(static batch => !batch.IsEmpty) - .Collect(); + .WithTrackingName(TrackingNames.DiscoveryBatches); - var discovered = discoveryBatches.Select(static (batches, _) => - FlattenAndSort(batches.Select(static b => b.Entries))); + var collectedBatches = discoveryBatches.Collect(); - var discoveryDiagnostics = discoveryBatches.Select(static (batches, _) => + var discovered = collectedBatches.Select(static (batches, _) => + FlattenAndSort(batches.Select(static b => b.Entries))) + .WithTrackingName(TrackingNames.DiscoveredEntries); + + var discoveryDiagnostics = collectedBatches.Select(static (batches, _) => new EquatableArray(batches.SelectMany(static b => (IEnumerable)b.Diagnostics))); context.RegisterSourceOutput(discoveryDiagnostics, static (spc, diagnostics) => @@ -118,7 +122,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.ReportDiagnostic(ToDiagnostic(info)); }); - var combined = methodCandidates.Combine(discovered); + var combined = methodCandidates.Combine(discovered) + .WithTrackingName(TrackingNames.RegistrationInputs); context.RegisterSourceOutput(combined, static (spc, pair) => { diff --git a/src/Paramore.Brighter.SourceGenerators/TrackingNames.cs b/src/Paramore.Brighter.SourceGenerators/TrackingNames.cs new file mode 100644 index 0000000000..4152820952 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/TrackingNames.cs @@ -0,0 +1,46 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Brighter.SourceGenerators; + +/// +/// Names attached to the incremental pipeline stages via WithTrackingName. They have no +/// effect on generated output; they exist so incrementality tests can assert that a stage was +/// cached (not re-executed) when an edit doesn't change the semantically relevant shape of the +/// compilation. A stage that flips to Modified on an unrelated edit is the classic symptom +/// of a pipeline value that lost its value equality. +/// +public static class TrackingNames +{ + /// The per-method [BrighterRegistrations] projection (MethodCandidate). + public const string MethodCandidates = "MethodCandidates"; + + /// The per-class discovery projection (DiscoveryBatch). + public const string DiscoveryBatches = "DiscoveryBatches"; + + /// The flattened, sorted registration entries for the whole compilation. + public const string DiscoveredEntries = "DiscoveredEntries"; + + /// The per-method candidate combined with the discovered entries, ready to emit. + public const string RegistrationInputs = "RegistrationInputs"; +} diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs index 77b6a4d120..f5d549a045 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/IncrementalCachingTests.cs @@ -1,16 +1,16 @@ -using System.Collections.Immutable; +using System; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Paramore.Brighter.SourceGenerators.Tests; /// -/// Verifies that the incremental pipeline's outputs are cached when an edit doesn't change -/// the semantically relevant shape of the compilation. These tests exercise the actual -/// IncrementalStepRunReason values that Roslyn records — the contract that "the same input -/// produces a cached result" is what determines real-world IDE responsiveness. +/// Verifies that the incremental pipeline really is incremental: when an edit doesn't change the +/// semantically relevant shape of the compilation, the model stages must not be re-computed into a +/// new value. The classic failure is a pipeline value that lost value equality (a record holding a +/// raw array, or a Roslyn symbol that escaped a transform) — that shows up here as a tracked stage +/// flipping to on an unrelated edit. /// public class IncrementalCachingTests { @@ -37,6 +37,14 @@ public static partial class Registrations } """; + private static readonly string[] ModelStages = + { + TrackingNames.MethodCandidates, + TrackingNames.DiscoveryBatches, + TrackingNames.DiscoveredEntries, + TrackingNames.RegistrationInputs, + }; + private static (GeneratorDriver Driver, Compilation Compilation) RunInitial(string source) { var references = new[] @@ -60,41 +68,61 @@ private static (GeneratorDriver Driver, Compilation Compilation) RunInitial(stri return (driver, compilation); } + private static IncrementalStepRunReason[] StageReasons(GeneratorRunResult result, string stage) => + result.TrackedSteps.TryGetValue(stage, out var steps) + ? steps.SelectMany(s => s.Outputs).Select(o => o.Reason).ToArray() + : Array.Empty(); + + private static IncrementalStepRunReason[] OutputReasons(GeneratorRunResult result) => + result.TrackedOutputSteps.SelectMany(kv => kv.Value).SelectMany(s => s.Outputs).Select(o => o.Reason).ToArray(); + + private static void AssertNotRecomputed(GeneratorRunResult result, string stage) + { + var reasons = StageReasons(result, stage); + Assert.NotEmpty(reasons); + Assert.All(reasons, reason => Assert.True( + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"Stage '{stage}' was {reason}; expected Cached or Unchanged. " + + "A Modified/New here means a pipeline value lost its value equality.")); + } + [Fact] - public void UnrelatedSyntaxEdit_CachesAllOutputs() + public void UnrelatedSyntaxEdit_ModelStagesAreNotInvalidated() { var (driver, compilation) = RunInitial(UserSource); - // Touch the source: add a blank line at the bottom. Nothing semantically relevant changes. - var edited = compilation.SyntaxTrees.Single(); - var editedText = edited.GetText().ToString() + "\n// trailing comment\n"; - var newCompilation = compilation - .ReplaceSyntaxTree(edited, CSharpSyntaxTree.ParseText(editedText)); - - driver = driver.RunGenerators(newCompilation); - - var runResult = driver.GetRunResult(); - var stepReasons = runResult.Results - .Single() - .TrackedOutputSteps - .SelectMany(kv => kv.Value) - .SelectMany(step => step.Outputs) - .Select(output => output.Reason) - .ToArray(); - - Assert.NotEmpty(stepReasons); - Assert.All(stepReasons, reason => - Assert.True( - reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, - $"Expected Cached or Unchanged but got {reason}")); + // Touch the source: append a trailing comment. Nothing semantically relevant changes, so + // the transforms may re-run but must produce equal values (Unchanged), and everything + // downstream of the Collect must stay Cached. + var tree = compilation.SyntaxTrees.Single(); + var newCompilation = compilation.ReplaceSyntaxTree( + tree, CSharpSyntaxTree.ParseText(tree.GetText().ToString() + "\n// trailing comment\n")); + + var result = driver.RunGenerators(newCompilation).GetRunResult().Results.Single(); + + foreach (var stage in ModelStages) + AssertNotRecomputed(result, stage); + + // Stages downstream of the Collect see an unchanged collected input, so they are fully + // cached (never even re-executed). This is the strongest guarantee that equality held. + Assert.All(StageReasons(result, TrackingNames.DiscoveredEntries), + r => Assert.Equal(IncrementalStepRunReason.Cached, r)); + Assert.All(StageReasons(result, TrackingNames.RegistrationInputs), + r => Assert.Equal(IncrementalStepRunReason.Cached, r)); + + // And no generated output is regenerated. + Assert.All(OutputReasons(result), r => Assert.True( + r is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"A source output was {r}; expected Cached or Unchanged.")); } [Fact] - public void AddingUnrelatedClass_CachesRegistrationOutput() + public void AddingUnrelatedClass_DoesNotInvalidateRegistration() { var (driver, compilation) = RunInitial(UserSource); - // Add a completely unrelated class in a new tree — nothing implementing handler/mapper. + // A new file with a class that implements no Brighter interface contributes zero entries, + // so the discovered-entries list — and therefore the registration — is unchanged. var newTree = CSharpSyntaxTree.ParseText(""" namespace App.Other; @@ -103,43 +131,22 @@ public class Bystander public int X { get; set; } } """); - var newCompilation = compilation.AddSyntaxTrees(newTree); - - driver = driver.RunGenerators(newCompilation); - - var runResult = driver.GetRunResult(); - var allReasons = runResult.Results - .Single() - .TrackedSteps - .SelectMany(kv => kv.Value) - .SelectMany(step => step.Outputs) - .Select(output => output.Reason) - .ToArray(); - - // The new class adds a fresh "ReadClass" run (New), but downstream of FlattenAndSort the - // discovered list must be Unchanged because the new class produces zero entries. - Assert.Contains(IncrementalStepRunReason.Unchanged, allReasons); - - // And the final generated source step shouldn't be Modified. - var sourceReasons = runResult.Results - .Single() - .TrackedOutputSteps - .SelectMany(kv => kv.Value) - .SelectMany(step => step.Outputs) - .Select(o => o.Reason) - .ToArray(); - Assert.All(sourceReasons, reason => - Assert.True( - reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, - $"Expected Cached or Unchanged for output step but got {reason}")); + var result = driver.RunGenerators(compilation.AddSyntaxTrees(newTree)).GetRunResult().Results.Single(); + + AssertNotRecomputed(result, TrackingNames.DiscoveredEntries); + AssertNotRecomputed(result, TrackingNames.RegistrationInputs); + Assert.All(OutputReasons(result), r => Assert.True( + r is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, + $"A source output was {r}; expected Cached or Unchanged.")); } [Fact] - public void AddingNewHandler_RegeneratesRegistrationOutput() + public void AddingNewHandler_InvalidatesRegistration() { var (driver, compilation) = RunInitial(UserSource); - // Add a NEW handler in a separate file. This must invalidate the output. + // Adding a real handler must flow through to a regenerated registration — the positive + // control proving the cache invalidates when it genuinely should. var newTree = CSharpSyntaxTree.ParseText(""" using Paramore.Brighter; @@ -155,24 +162,12 @@ public class OtherHandler : RequestHandler public override OtherCommand Handle(OtherCommand command) => base.Handle(command); } """); - var newCompilation = compilation.AddSyntaxTrees(newTree); - - driver = driver.RunGenerators(newCompilation); - - var runResult = driver.GetRunResult(); - var sourceReasons = runResult.Results - .Single() - .TrackedOutputSteps - .SelectMany(kv => kv.Value) - .SelectMany(step => step.Outputs) - .Select(o => o.Reason) - .ToArray(); + var result = driver.RunGenerators(compilation.AddSyntaxTrees(newTree)).GetRunResult().Results.Single(); - // At least one source output must be Modified (the registration file). - Assert.Contains(IncrementalStepRunReason.Modified, sourceReasons); + Assert.Contains(IncrementalStepRunReason.Modified, StageReasons(result, TrackingNames.DiscoveredEntries)); + Assert.Contains(IncrementalStepRunReason.Modified, OutputReasons(result)); - // And the generated source itself must mention the new handler. - var generated = runResult.Results.Single().GeneratedSources + var generated = result.GeneratedSources .First(gs => gs.HintName.EndsWith("__AddFromThisAssembly.g.cs")) .SourceText.ToString(); Assert.Contains("global::App.OtherHandler", generated); From e9a18ee878cb3ca02d84c13defa435338ba59e64 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Thu, 4 Jun 2026 23:36:33 +0100 Subject: [PATCH 12/20] fix: address PR review findings - Discover handlers/mappers/transforms on any partial declaration, not just the 'primary' one: drop IsPrimaryDeclaration (dedup is already handled by RegistrationModel.From's Distinct), fixing the case where the base list lives on a non-primary partial file and the type was silently never registered. - Suppress the auto-generated BrighterAssemblyRegistrations class when the compilation hand-writes a [BrighterRegistrations] method, avoiding double registration and the ambiguous AddFromThisAssembly call. - Discover record (not just class) mappers and transforms. - Memoise MarkerSymbols.Resolve per-compilation via a weak-keyed table so the 8 metadata lookups run once per pass instead of per node. - Remove a redundant !type.IsGenericType clause. - Replace the RS2008 suppression with proper analyzer release tracking (AnalyzerReleases.Shipped/Unshipped.md for BRGEN001-005). - Document accessibility asymmetry, open-generic coupling, handler-inheritance double registration, and deferred follow-ups (packaging, Roslyn floor) in ADR 0062. Adds regression tests for the partial-class, auto/manual collision, and record mapper cases. Co-Authored-By: Claude Opus 4.8 --- ...2-source-generated-handler-registration.md | 37 ++++++++ .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 12 +++ .../BrighterRegistrationsGenerator.cs | 24 +++-- .../MarkerSymbols.cs | 13 ++- .../Paramore.Brighter.SourceGenerators.csproj | 8 +- .../SemanticModelReader.cs | 28 ++---- .../AutoRegistrationTests.cs | 40 ++++++++ .../BrighterRegistrationsGeneratorTests.cs | 93 +++++++++++++++++++ 9 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Shipped.md create mode 100644 src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md diff --git a/docs/adr/0062-source-generated-handler-registration.md b/docs/adr/0062-source-generated-handler-registration.md index 08d812e1f6..500f261190 100644 --- a/docs/adr/0062-source-generated-handler-registration.md +++ b/docs/adr/0062-source-generated-handler-registration.md @@ -105,6 +105,11 @@ registered as-is and produce diagnostic `BRGEN005` (warning) rather than being s Malformed registration methods produce `BRGEN001`–`BRGEN004` (errors), giving the compile-time feedback that scanning never could. +Discovery covers both `class` and `record` declarations: handlers must derive from +`RequestHandler` (so are always classes), but mappers and transforms implement interfaces only +and may legitimately be records. A type is reachable when it (and any containing type) is `public` +**or** `internal` — see the accessibility note under Consequences. + ### Per-package auto-registration (the referenced-assembly story) A source generator only sees the syntax of the **current** compilation; types compiled into a @@ -118,6 +123,11 @@ extension covering *that compilation's* types. Because the prop is in `build/` ( own registrations at its own compile time and the consumer calls `AddFromThisAssembly()` — rather than the consumer trying to load and scan the library at runtime. +The auto class is **suppressed when the compilation also hand-writes a `[BrighterRegistrations]` +method**, so the default-on auto path and the documented manual path cannot both register the same +types (double registration) or collide on the `AddFromThisAssembly(this IBrighterBuilder)` name +(`CS0121` ambiguous call). The hand-written method takes precedence. + ## Consequences ### Positive @@ -146,6 +156,33 @@ than the consumer trying to load and scan the library at runtime. know which they are using (and that they are additive). - **Generic mappers/transforms are unsupported** (by design) and require a closed type, a non-generic wrapper, or an explicit exclude. +- **Accessibility asymmetry with `AutoFromAssemblies`.** The reflection scanner registers only + `public` (or nested-public) handlers; the generator also registers `internal` types, since + generated code lives in the same assembly and `internal` is genuinely reachable. This is arguably + better, but a team switching mechanisms may see `internal` handlers appear/disappear. +- **Open-generic registration is coupled to `ServiceCollectionSubscriberRegistry`.** Open generics + emit a `(ServiceCollectionSubscriberRegistry)r` cast to reach `EnsureHandlerIsRegistered`. That + holds for the shipped DI extension; if `IBrighterBuilder.Handlers` ever supplied a different + `IAmASubscriberRegistry`, the generated cast would throw `InvalidCastException` at runtime. +- **Handler inheritance can register a request type twice.** For `class B : A` where `A : + RequestHandler`, both `A` and `B` report `IHandleRequests`, so both are registered. This + matches the reflection scanner's behaviour rather than introducing a new asymmetry. + +### Deferred follow-ups + +These are accepted gaps, tracked rather than fixed in this change: + +- **Packaging is not yet wired up.** `IsPackable=false` and there is no companion `*.Package` + project, so `dotnet pack` produces nothing and the `build/`-props direct-vs-transitive story is + not yet exercised by a real package consumer — only the in-repo `ProjectReference` / + `OutputItemType="Analyzer"` path (used by the sample). Shipping should follow the existing + `Paramore.Brighter.Analyzer.Package` pattern. +- **Roslyn version floor.** The generator references `Microsoft.CodeAnalysis.CSharp` at the + repo-pinned version. Since a generator's referenced Roslyn version sets the *minimum* compiler/SDK + a consumer needs, before packaging it should be pinned to the lowest version providing the APIs + used (`ForAttributeWithMetadataName`, 4.3.1+) to maximise the range of consuming SDKs. +- **Public surface.** The reader/writer/model types are `public` for testability; for a + dev-dependency generator they could be `internal` + `InternalsVisibleTo`. Harmless either way. ### Risks and Mitigations diff --git a/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Shipped.md b/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..f50bb1fe21 --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000000..1c47b58bcb --- /dev/null +++ b/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,12 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +BRGEN001 | Brighter | Error | Brighter registration method must be partial +BRGEN002 | Brighter | Error | Brighter registration method must be static +BRGEN003 | Brighter | Error | Brighter registration method must return IBrighterBuilder +BRGEN004 | Brighter | Error | Brighter registration method must take a single IBrighterBuilder parameter +BRGEN005 | Brighter | Warning | Generic message mappers and transforms are not registered diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index d49a32e3bd..b78f37aed1 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -102,7 +102,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var discoveryBatches = context.SyntaxProvider .CreateSyntaxProvider( - predicate: static (node, _) => node is ClassDeclarationSyntax cls && cls.BaseList is not null, + // class or record (mappers/transforms implement interfaces, so they can be records); + // IsClassifiable still filters to TypeKind.Class, excluding structs/record structs. + predicate: static (node, _) => + node is ClassDeclarationSyntax { BaseList: not null } + or RecordDeclarationSyntax { BaseList: not null }, transform: static (ctx, ct) => SemanticModelReader.ReadClass(ctx, ct)) .Where(static batch => !batch.IsEmpty) .WithTrackingName(TrackingNames.DiscoveryBatches); @@ -142,7 +146,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); }); - RegisterAutoRegistration(context, discovered); + // True when the compilation hand-writes at least one valid [BrighterRegistrations] method. + // Used to suppress the auto class so the two paths can't both register (double registration, + // or an ambiguous AddFromThisAssembly call when the manual method shares the name). + var hasManualRegistration = methodCandidates + .Collect() + .Select(static (candidates, _) => candidates.Any(static c => c.Method is not null)); + + RegisterAutoRegistration(context, discovered, hasManualRegistration); } /// @@ -153,7 +164,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) /// private static void RegisterAutoRegistration( IncrementalGeneratorInitializationContext context, - IncrementalValueProvider> discovered) + IncrementalValueProvider> discovered, + IncrementalValueProvider hasManualRegistration) { var autoEnabled = context.AnalyzerConfigOptionsProvider .Select(static (provider, _) => @@ -164,12 +176,12 @@ private static void RegisterAutoRegistration( var brighterAvailable = context.CompilationProvider .Select(static (c, _) => MarkerSymbols.Resolve(c).IsValid); - var autoInputs = autoEnabled.Combine(brighterAvailable).Combine(discovered); + var autoInputs = autoEnabled.Combine(brighterAvailable).Combine(discovered).Combine(hasManualRegistration); context.RegisterSourceOutput(autoInputs, static (spc, pair) => { - var ((enabled, available), entries) = pair; - if (!enabled || !available) + var (((enabled, available), entries), hasManual) = pair; + if (!enabled || !available || hasManual) return; var target = BuildAutoTarget(); diff --git a/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs b/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs index f404dd67cf..2ee264e46e 100644 --- a/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs +++ b/src/Paramore.Brighter.SourceGenerators/MarkerSymbols.cs @@ -21,6 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #endregion +using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; namespace Paramore.Brighter.SourceGenerators; @@ -49,7 +50,17 @@ MessageMapperAsync is not null && MessageTransform is not null && MessageTransformAsync is not null; - public static MarkerSymbols Resolve(Compilation c) => new() + // Resolving the markers does eight metadata lookups; the discovery/method transforms run + // per-node, so without this every base-listed class and attributed method would repeat the + // work. All nodes in a single generator pass share one Compilation instance, so memoising by + // Compilation collapses it to one resolve per pass. The table holds weak keys, so an entry is + // collected once its Compilation is — nothing is leaked across edits. + private static readonly ConditionalWeakTable s_cache = new(); + + public static MarkerSymbols Resolve(Compilation c) => + s_cache.GetValue(c, static compilation => Create(compilation)); + + private static MarkerSymbols Create(Compilation c) => new() { BrighterBuilder = c.GetTypeByMetadataName("Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder"), HandleRequests = c.GetTypeByMetadataName("Paramore.Brighter.IHandleRequests`1"), diff --git a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj index 8d1dd3179e..5f5b4924ca 100644 --- a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj +++ b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj @@ -13,14 +13,18 @@ false false - - $(NoWarn);RS2008 + + + + + + diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index c7561597ab..74581b7cb4 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -71,7 +71,7 @@ public static DiscoveryBatch ReadClass(GeneratorSyntaxContext ctx, CancellationT { cancellationToken.ThrowIfCancellationRequested(); - if (ctx.Node is not ClassDeclarationSyntax cls) + if (ctx.Node is not TypeDeclarationSyntax cls) return DiscoveryBatch.Empty; if (ctx.SemanticModel.GetDeclaredSymbol(cls, cancellationToken) is not INamedTypeSymbol type) @@ -80,11 +80,11 @@ public static DiscoveryBatch ReadClass(GeneratorSyntaxContext ctx, CancellationT if (!IsClassifiable(type)) return DiscoveryBatch.Empty; - // Only emit from the "primary" partial declaration so partial classes don't get - // discovered N times. Order explicitly so the choice is self-evidently stable. - if (!IsPrimaryDeclaration(type, cls)) - return DiscoveryBatch.Empty; - + // A partial type can carry its base/interface list on more than one declaration, so the + // same type may reach this transform multiple times. We deliberately do NOT dedup here on + // the "primary" declaration: that drops the type entirely when the primary declaration is + // the one without a base list (it never reaches the predicate). Duplicate entries are + // collapsed instead by RegistrationModel.From's Distinct(), which is order-independent. var markers = MarkerSymbols.Resolve(ctx.SemanticModel.Compilation); if (!markers.IsValid) return DiscoveryBatch.Empty; @@ -160,7 +160,8 @@ private static void ClassifyEntries( entries.Add(entry); } - if (seenTransform && !type.IsGenericType) + // seenTransform is only set on the non-generic branch, so it already implies !IsGenericType. + if (seenTransform) entries.Add(new DiscoveredEntry(DiscoveredKind.Transform, string.Empty, FullyQualified(type), IsOpenGeneric: false)); if (unsupportedGenericMapperOrTransform) @@ -229,19 +230,6 @@ private static bool IsClassifiable(INamedTypeSymbol type) return IsReachableFromGeneratedCode(type); } - private static bool IsPrimaryDeclaration(INamedTypeSymbol type, ClassDeclarationSyntax cls) - { - var refs = type.DeclaringSyntaxReferences; - if (refs.Length <= 1) - return true; - // Deterministic primary pick that doesn't rely on undocumented Roslyn ordering. - var primary = refs - .OrderBy(r => r.SyntaxTree.FilePath, System.StringComparer.Ordinal) - .ThenBy(r => r.Span.Start) - .First(); - return primary.SyntaxTree == cls.SyntaxTree && primary.Span == cls.Span; - } - private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) { for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs index f57ac8819a..06b915c805 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs @@ -31,6 +31,29 @@ public class GreetingHandler : RequestHandler } """; + private const string UserSourceWithManualRegistration = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public class GreetingHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + private static GeneratorDriverRunResult Run(string source, string? autoRegistrationValue) { var references = new[] @@ -102,6 +125,23 @@ public void PropertyMissing_DoesNotGenerateAutoClass() g => g.HintName == "BrighterAssemblyRegistrations__AddFromThisAssembly.g.cs"); } + [Fact] + public void PropertyTrue_WithManualRegistrationMethod_SuppressesAutoClass() + { + // Auto-on + a hand-written [BrighterRegistrations] method would otherwise double-register + // (and collide on the AddFromThisAssembly name). The manual method wins; auto is suppressed. + var single = Run(UserSourceWithManualRegistration, "true").Results.Single(); + + Assert.DoesNotContain( + single.GeneratedSources, + g => g.HintName == "BrighterAssemblyRegistrations__AddFromThisAssembly.g.cs"); + + // The user's own registration method is still implemented. + Assert.Contains( + single.GeneratedSources, + g => g.HintName.StartsWith("App_Registrations") && g.HintName.EndsWith("__AddFromThisAssembly.g.cs")); + } + private sealed class StubOptionsProvider : AnalyzerConfigOptionsProvider { private readonly StubOptions _global; diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs index 7b1815517b..0293056e14 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs @@ -447,6 +447,99 @@ public static class Registrations await test.RunAsync(); } + [Fact] + public async Task RecordMapper_IsDiscovered() + { + // Mappers/transforms implement interfaces only, so they may legitimately be records. + const string userCode = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingEvent : Event { public GreetingEvent() : base(System.Guid.NewGuid()) { } } + + public record GreetingMapper : IAmAMessageMapper + { + public IRequestContext? Context { get; set; } + public Message MapToMessage(GreetingEvent request, Publication publication) => new(); + public GreetingEvent MapToRequest(Message message) => new(); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(userCode); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(Registration(""" + builder.MapperRegistry(r => + { + r.Add(typeof(global::App.GreetingEvent), typeof(global::App.GreetingMapper)); + }); + """)); + + await test.RunAsync(); + } + + [Fact] + public async Task PartialHandler_WithBaseListOnSecondaryFile_IsRegistered() + { + // The declaration WITHOUT the base list sorts first ("primary"); the base list lives on the + // second file. Regression test for the dropped-registration bug — the handler must still be + // discovered and registered. + const string partA = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public partial class SplitHandler + { + public void Helper() { } + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """; + + const string partB = """ + using Paramore.Brighter; + + namespace App; + + public partial class SplitHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + """; + + var test = MakeTest(); + test.TestState.Sources.Add(partA); + test.TestState.Sources.Add(partB); + test.TestState.GeneratedSources.Add(AttributeFile()); + test.TestState.GeneratedSources.Add(Registration(""" + builder.Handlers(r => + { + r.Register(); + }); + """)); + + await test.RunAsync(); + } + private const string RegistrationHint = "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs"; /// From b563fdbc28ad1ca1d278cfa9167a0ae67ba401c9 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 5 Jun 2026 00:18:09 +0100 Subject: [PATCH 13/20] fix: address second review batch - Types nested in an open generic (e.g. Outer.Handler) are no longer classified for registration: they crashed the generator (UnboundGenericName computed a negative count) or emitted code referencing unbound type params. They are now reported as BRGEN006 before classification. - De-duplicate discovery diagnostics: a generic type whose base list is split across partial declarations reached ReadClass once per file, producing duplicate BRGEN005s; the flattened diagnostics are now Distinct(). - Emit BRGEN007 (info) when auto-registration is suppressed by a manual [BrighterRegistrations] method, so the vanished auto class is explained. - Write the generated banner line-by-line so a file's line endings are consistent instead of mixing the banner's LF with Environment.NewLine. - Reduce ReadClass complexity (extract GetDiscoverableType) and split the RegisterAutoRegistration guard (CodeScene). Adds tests for the nested-open-generic (BRGEN006), split-partial diagnostic de-dup, and auto-suppression (BRGEN007) cases. Co-Authored-By: Claude Opus 4.8 --- ...2-source-generated-handler-registration.md | 6 +- .../AnalyzerReleases.Unshipped.md | 2 + .../BrighterRegistrationsGenerator.cs | 16 +++- .../Diagnostics.cs | 12 +++ .../GeneratedSource.cs | 7 ++ .../RegistrationWriter.cs | 4 +- .../SemanticModelReader.cs | 93 +++++++++++++++---- .../AutoRegistrationTests.cs | 3 + .../BrighterRegistrationsGeneratorTests.cs | 90 ++++++++++++++++++ 9 files changed, 210 insertions(+), 23 deletions(-) diff --git a/docs/adr/0062-source-generated-handler-registration.md b/docs/adr/0062-source-generated-handler-registration.md index 500f261190..7abacb90ff 100644 --- a/docs/adr/0062-source-generated-handler-registration.md +++ b/docs/adr/0062-source-generated-handler-registration.md @@ -103,7 +103,11 @@ emitting calls that register every discovered type: `[ExcludeFromBrighterRegistration]` opts a single type out. Generic mappers/transforms cannot be registered as-is and produce diagnostic `BRGEN005` (warning) rather than being silently dropped. Malformed registration methods produce `BRGEN001`–`BRGEN004` (errors), giving the compile-time -feedback that scanning never could. +feedback that scanning never could. A Brighter type declared inside an *open generic* type cannot be +named with concrete type arguments at the registration site, so it is reported as `BRGEN006` +(warning) instead of emitting code that would not compile. When auto-registration is enabled but a +manual `[BrighterRegistrations]` method is present, the suppressed auto path is reported as +`BRGEN007` (info). Discovery covers both `class` and `record` declarations: handlers must derive from `RequestHandler` (so are always classes), but mappers and transforms implement interfaces only diff --git a/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md index 1c47b58bcb..6fd111b389 100644 --- a/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/Paramore.Brighter.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -10,3 +10,5 @@ BRGEN002 | Brighter | Error | Brighter registration method must be static BRGEN003 | Brighter | Error | Brighter registration method must return IBrighterBuilder BRGEN004 | Brighter | Error | Brighter registration method must take a single IBrighterBuilder parameter BRGEN005 | Brighter | Warning | Generic message mappers and transforms are not registered +BRGEN006 | Brighter | Warning | Types nested in an open generic type are not registered +BRGEN007 | Brighter | Info | Auto-registration suppressed by a manual registration method diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index b78f37aed1..b491493a9f 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -117,8 +117,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) FlattenAndSort(batches.Select(static b => b.Entries))) .WithTrackingName(TrackingNames.DiscoveredEntries); + // Distinct() because a generic type whose base list is split across partial declarations + // reaches ReadClass once per declaration, producing identical diagnostics (same id + location). var discoveryDiagnostics = collectedBatches.Select(static (batches, _) => - new EquatableArray(batches.SelectMany(static b => (IEnumerable)b.Diagnostics))); + new EquatableArray( + batches.SelectMany(static b => (IEnumerable)b.Diagnostics).Distinct())); context.RegisterSourceOutput(discoveryDiagnostics, static (spc, diagnostics) => { @@ -181,9 +184,17 @@ private static void RegisterAutoRegistration( context.RegisterSourceOutput(autoInputs, static (spc, pair) => { var (((enabled, available), entries), hasManual) = pair; - if (!enabled || !available || hasManual) + if (!enabled || !available) return; + // A hand-written [BrighterRegistrations] method takes precedence; tell the user why the + // auto class disappeared rather than leaving them to discover it. + if (hasManual) + { + spc.ReportDiagnostic(Diagnostic.Create(Diagnostics.AutoRegistrationSuppressed, Location.None)); + return; + } + var target = BuildAutoTarget(); var model = RegistrationModel.From(target, entries); spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); @@ -230,6 +241,7 @@ private static Diagnostic ToDiagnostic(DiagnosticInfo info) => Diagnostic.Create "BRGEN003" => Diagnostics.WrongReturnType, "BRGEN004" => Diagnostics.WrongSignature, "BRGEN005" => Diagnostics.GenericMapperOrTransformIgnored, + "BRGEN006" => Diagnostics.NestedInOpenGeneric, _ => throw new System.InvalidOperationException($"Unknown Brighter diagnostic id '{id}' — DescriptorFor needs updating."), }; } diff --git a/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs b/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs index a84507b44f..42916575c3 100644 --- a/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs +++ b/src/Paramore.Brighter.SourceGenerators/Diagnostics.cs @@ -56,4 +56,16 @@ public static class Diagnostics "Generic message mappers and transforms are not registered", "Generic type '{0}' implements a Brighter mapper or transform interface but won't be auto-registered; close the generic, write a non-generic wrapper, or mark it with [ExcludeFromBrighterRegistration]", "Brighter", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor NestedInOpenGeneric = new( + "BRGEN006", + "Types nested in an open generic type are not registered", + "Type '{0}' is declared inside an open generic type, so its name cannot be written with concrete type arguments at the registration site; move it out of the open generic type, or mark it with [ExcludeFromBrighterRegistration]", + "Brighter", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor AutoRegistrationSuppressed = new( + "BRGEN007", + "Auto-registration suppressed by a manual registration method", + "Brighter auto-registration is enabled but a manual [BrighterRegistrations] method is present, so the generated BrighterAssemblyRegistrations class was not emitted; remove the manual method or set false", + "Brighter", DiagnosticSeverity.Info, isEnabledByDefault: true); } diff --git a/src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs b/src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs index aa5186fe60..cf8c21b936 100644 --- a/src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs +++ b/src/Paramore.Brighter.SourceGenerators/GeneratedSource.cs @@ -54,6 +54,13 @@ public static class GeneratedSource //------------------------------------------------------------------------------ """; + /// + /// split into individual lines so an emitter can write each through its own + /// writer (and thus its own newline), keeping a generated file's line endings consistent rather + /// than mixing the banner's literal LF with the writer's Environment.NewLine. + /// + public static string[] HeaderLines { get; } = Header.Replace("\r\n", "\n").Split('\n'); + /// /// The [System.CodeDom.Compiler.GeneratedCode(...)] attribute, fully qualified so it can /// be emitted into any namespace without a using directive. diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index ffbbaebbee..5623043d6f 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -40,7 +40,9 @@ public static string Write(RegistrationModel model) var buffer = new StringWriter(); using var code = new CodeWriter(buffer); - code.WriteLine(GeneratedSource.Header); + // Write the banner line-by-line so it shares the writer's newline (consistent line endings). + foreach (var line in GeneratedSource.HeaderLines) + code.WriteLine(line); code.WriteLine("#nullable enable"); code.WriteLineNoTabs(string.Empty); diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index 74581b7cb4..878defd577 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -71,30 +71,15 @@ public static DiscoveryBatch ReadClass(GeneratorSyntaxContext ctx, CancellationT { cancellationToken.ThrowIfCancellationRequested(); - if (ctx.Node is not TypeDeclarationSyntax cls) - return DiscoveryBatch.Empty; - - if (ctx.SemanticModel.GetDeclaredSymbol(cls, cancellationToken) is not INamedTypeSymbol type) - return DiscoveryBatch.Empty; - - if (!IsClassifiable(type)) - return DiscoveryBatch.Empty; - - // A partial type can carry its base/interface list on more than one declaration, so the - // same type may reach this transform multiple times. We deliberately do NOT dedup here on - // the "primary" declaration: that drops the type entirely when the primary declaration is - // the one without a base list (it never reaches the predicate). Duplicate entries are - // collapsed instead by RegistrationModel.From's Distinct(), which is order-independent. - var markers = MarkerSymbols.Resolve(ctx.SemanticModel.Compilation); - if (!markers.IsValid) - return DiscoveryBatch.Empty; - - if (markers.ExcludeAttribute is not null && HasAttribute(type, markers.ExcludeAttribute)) + var discoverable = GetDiscoverableType(ctx, cancellationToken); + if (discoverable is null) return DiscoveryBatch.Empty; + var (type, markers) = discoverable.Value; var entries = new List(); var diagnostics = new List(); ClassifyEntries(type, markers, entries, diagnostics); + if (entries.Count == 0 && diagnostics.Count == 0) return DiscoveryBatch.Empty; return new DiscoveryBatch( @@ -102,6 +87,33 @@ public static DiscoveryBatch ReadClass(GeneratorSyntaxContext ctx, CancellationT new EquatableArray(diagnostics)); } + /// + /// Resolve a syntax node to a classifiable type symbol and the framework markers, or null if + /// the node isn't a discoverable Brighter type. A partial type can carry its base/interface + /// list on more than one declaration, so the same type may reach this transform multiple times; + /// we deliberately do NOT dedup on a "primary" declaration (that drops the type when the primary + /// declaration is the one without a base list). Duplicate entries are collapsed downstream by + /// RegistrationModel.From's Distinct(). + /// + private static (INamedTypeSymbol Type, MarkerSymbols Markers)? GetDiscoverableType( + GeneratorSyntaxContext ctx, CancellationToken cancellationToken) + { + if (ctx.Node is not TypeDeclarationSyntax cls) + return null; + if (ctx.SemanticModel.GetDeclaredSymbol(cls, cancellationToken) is not INamedTypeSymbol type) + return null; + if (!IsClassifiable(type)) + return null; + + var markers = MarkerSymbols.Resolve(ctx.SemanticModel.Compilation); + if (!markers.IsValid) + return null; + if (markers.ExcludeAttribute is not null && HasAttribute(type, markers.ExcludeAttribute)) + return null; + + return (type, markers); + } + private static bool HasAttribute(INamedTypeSymbol type, INamedTypeSymbol attribute) => type.GetAttributes().Any(a => Same(a.AttributeClass, attribute)); @@ -150,6 +162,20 @@ private static void ClassifyEntries( List entries, List diagnostics) { + // A Brighter type declared inside an open generic can't be named with concrete type + // arguments at the registration call site, so any registration we emit would reference + // unbound type parameters and fail to compile. Surface it as BRGEN006 instead — and bail + // before classifying, because building entries for such a type would itself misfire. + if (IsNestedInOpenGeneric(type)) + { + if (ImplementsAnyBrighterInterface(type, markers)) + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NestedInOpenGeneric.Id, + LocationInfo.From(type.Locations.FirstOrDefault()), + FullyQualified(type))); + return; + } + var seenTransform = false; var unsupportedGenericMapperOrTransform = false; @@ -240,6 +266,33 @@ private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) return true; } + private static bool IsNestedInOpenGeneric(INamedTypeSymbol type) + { + for (var outer = type.ContainingType; outer is not null; outer = outer.ContainingType) + { + if (outer.IsGenericType) + return true; + } + return false; + } + + private static bool ImplementsAnyBrighterInterface(INamedTypeSymbol type, MarkerSymbols markers) + { + foreach (var iface in type.AllInterfaces) + { + if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync)) + return true; + if (iface.IsGenericType && iface.TypeArguments.Length == 1) + { + var def = iface.OriginalDefinition; + if (Same(def, markers.HandleRequests) || Same(def, markers.HandleRequestsAsync) + || Same(def, markers.MessageMapper) || Same(def, markers.MessageMapperAsync)) + return true; + } + } + return false; + } + private static string BuildHintName(INamedTypeSymbol containingType, string methodName) { var raw = containingType.ToDisplayString(); @@ -270,6 +323,8 @@ private static string FullyQualified(ITypeSymbol type) => private static bool IsOpenGeneric(INamedTypeSymbol type) => type.IsUnboundGenericType || (type.IsGenericType && type.IsDefinition); + // Only reached for top-level open generics: types nested in an open generic are filtered out + // earlier (BRGEN006), so the first '<' here always belongs to the type's own parameter list. private static string UnboundGenericName(INamedTypeSymbol type) { var name = FullyQualified(type); diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs index 06b915c805..a98ee7896a 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs @@ -140,6 +140,9 @@ public void PropertyTrue_WithManualRegistrationMethod_SuppressesAutoClass() Assert.Contains( single.GeneratedSources, g => g.HintName.StartsWith("App_Registrations") && g.HintName.EndsWith("__AddFromThisAssembly.g.cs")); + + // ...and the user is told why the auto class vanished (BRGEN007, info-level). + Assert.Contains(single.Diagnostics, d => d.Id == "BRGEN007"); } private sealed class StubOptionsProvider : AnalyzerConfigOptionsProvider diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs index 0293056e14..86fb67d83d 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs @@ -540,6 +540,96 @@ public partial class SplitHandler : RequestHandler await test.RunAsync(); } + [Fact] + public void HandlerNestedInOpenGeneric_ReportsBRGEN006_AndEmitsNoBrokenRegistration() + { + // A handler nested in an open generic can't be named with concrete type args at the call + // site; the generator must surface BRGEN006 rather than emit code referencing unbound T. + var result = RunDriver(""" + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public class Outer + { + public class Handler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } + """); + + Assert.Contains(result.Diagnostics, d => d.Id == "BRGEN006"); + + var generated = GeneratedText(result); + Assert.DoesNotContain(".Handlers(", generated); + Assert.DoesNotContain("Outer", generated); + } + + [Fact] + public void GenericMapperSplitAcrossPartials_ReportsSingleBRGEN005() + { + // Each base-list-bearing partial declaration reaches ReadClass and emits BRGEN005; the + // flattened diagnostics must be de-duplicated so the user sees it once, not once per file. + var result = RunDriver( + """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingEvent : Event { public GreetingEvent() : base(System.Guid.NewGuid()) { } } + + public partial class OpenMapper : IAmAMessageMapper + { + public IRequestContext? Context { get; set; } + public Message MapToMessage(GreetingEvent request, Publication publication) => new(); + public GreetingEvent MapToRequest(Message message) => new(); + } + """, + """ + namespace App; + + public partial class OpenMapper : System.IDisposable + { + public void Dispose() { } + } + """); + + Assert.Equal(1, result.Diagnostics.Count(d => d.Id == "BRGEN005")); + } + + private static GeneratorRunResult RunDriver(params string[] sources) + { + var references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Paramore.Brighter.IRequest).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder).Assembly.Location), + }; + var trees = sources.Select(s => CSharpSyntaxTree.ParseText(s)).ToArray(); + var compilation = CSharpCompilation.Create( + "TestAsm", trees, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new BrighterRegistrationsGenerator().AsSourceGenerator() }); + return driver.RunGenerators(compilation).GetRunResult().Results.Single(); + } + + private static string GeneratedText(GeneratorRunResult result) => + string.Join("\n", result.GeneratedSources.Select(g => g.SourceText.ToString())); + private const string RegistrationHint = "App_Registrations_6a7651d4__AddFromThisAssembly.g.cs"; /// From 6b565a2ed59d6a65fdef7390b531d64801aadc7b Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 5 Jun 2026 09:27:28 +0100 Subject: [PATCH 14/20] refactor: reduce complexity flagged by CodeScene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RegistrationWriter: split WriteHandlers into closed/open-generic helpers and replace the type-keyword switch with conditional modifiers, lowering the module's mean cyclomatic complexity. - SemanticModelReader: extract a shared ClassifyInterface/GenericInterfaceKind so TryClassifyInterface and ImplementsAnyBrighterInterface no longer duplicate the interface-matching logic; ImplementsAnyBrighterInterface becomes a one-liner. Replace the AccessibilityModifier switch with a lookup. - RegisterAutoRegistration: replace the multi-condition guard with single- condition guards (no complex conditional). Pure structural change — generated output is byte-identical (snapshot tests unchanged); all 33 tests pass. Co-Authored-By: Claude Opus 4.8 --- .../BrighterRegistrationsGenerator.cs | 4 +- .../RegistrationWriter.cs | 52 ++++----- .../SemanticModelReader.cs | 107 ++++++++++-------- 3 files changed, 85 insertions(+), 78 deletions(-) diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index b491493a9f..3360fdc4ed 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -184,7 +184,9 @@ private static void RegisterAutoRegistration( context.RegisterSourceOutput(autoInputs, static (spc, pair) => { var (((enabled, available), entries), hasManual) = pair; - if (!enabled || !available) + if (!enabled) + return; + if (!available) return; // A hand-written [BrighterRegistrations] method takes precedence; tell the user why the diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index 5623043d6f..313f6dbb15 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -22,6 +22,7 @@ THE SOFTWARE. */ #endregion using System.IO; +using System.Linq; using System.Text; using Paramore.Brighter.SourceGenerators.Model; @@ -90,14 +91,9 @@ private static void WriteContainingType(CodeWriter code, RegistrationModel model private static string ContainingTypeDeclaration(RegistrationModel model) { - var typeKeyword = (model.ContainingTypeIsStatic, model.IsPartial) switch - { - (true, true) => "static partial class", - (true, false) => "static class", - (false, true) => "partial class", - (false, false) => "class", - }; - return $"{model.ContainingTypeAccessibility} {typeKeyword} {model.ContainingTypeName}"; + var modifiers = (model.ContainingTypeIsStatic ? "static " : string.Empty) + + (model.IsPartial ? "partial " : string.Empty); + return $"{model.ContainingTypeAccessibility} {modifiers}class {model.ContainingTypeName}"; } private static string MethodSignature(RegistrationModel model) @@ -124,37 +120,37 @@ private static void WriteHandlers( var callbackMethod = isAsync ? "AsyncHandlers" : "Handlers"; var registerMethod = isAsync ? "RegisterAsync" : "Register"; - var hasOpenGeneric = false; - foreach (var entry in entries) - { - if (entry.IsOpenGeneric) { hasOpenGeneric = true; break; } - } code.WriteLine($"{paramName}.{callbackMethod}(r =>"); code.StartBlock(); + WriteClosedHandlers(code, registerMethod, entries); + WriteOpenGenericHandlers(code, entries); + code.EndBlock(");"); + } - // For closed-generic handlers, use the strongly-typed Register() method - // available on the public interface — no implementation cast needed. + // For closed-generic handlers, use the strongly-typed Register() method + // available on the public interface — no implementation cast needed. + private static void WriteClosedHandlers(CodeWriter code, string registerMethod, EquatableArray entries) + { foreach (var entry in entries) { if (entry.IsOpenGeneric) continue; code.WriteLine($"r.{registerMethod}<{entry.RequestTypeFullyQualified}, {entry.HandlerTypeFullyQualified}>();"); } + } - // Open-generic handlers need EnsureHandlerIsRegistered, which only exists on the DI - // extension's concrete ServiceCollectionSubscriberRegistry. Emit the cast only when at - // least one open generic is present, so the common case stays interface-only. - if (hasOpenGeneric) - { - code.WriteLine("var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); - foreach (var entry in entries) - { - if (!entry.IsOpenGeneric) continue; - code.WriteLine($"registry.EnsureHandlerIsRegistered(typeof({entry.HandlerTypeFullyQualified}));"); - } - } + // Open-generic handlers need EnsureHandlerIsRegistered, which only exists on the DI extension's + // concrete ServiceCollectionSubscriberRegistry. Emit the cast only when at least one open generic + // is present, so the common case stays interface-only. + private static void WriteOpenGenericHandlers(CodeWriter code, EquatableArray entries) + { + var openGenerics = entries.Where(static e => e.IsOpenGeneric).ToList(); + if (openGenerics.Count == 0) + return; - code.EndBlock(");"); + code.WriteLine("var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;"); + foreach (var entry in openGenerics) + code.WriteLine($"registry.EnsureHandlerIsRegistered(typeof({entry.HandlerTypeFullyQualified}));"); } private static void WriteMappers( diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index 878defd577..1731a335fc 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -200,6 +200,8 @@ private static void ClassifyEntries( } } + private enum BrighterInterfaceKind { None, SyncHandler, AsyncHandler, Mapper, AsyncMapper, Transform } + private static DiscoveredEntry? TryClassifyInterface( INamedTypeSymbol type, INamedTypeSymbol iface, @@ -207,37 +209,45 @@ private static void ClassifyEntries( ref bool seenTransform, ref bool unsupportedGenericMapperOrTransform) { - if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync)) + switch (ClassifyInterface(iface, markers, out var requestType)) { - if (type.IsGenericType) - unsupportedGenericMapperOrTransform = true; - else - seenTransform = true; - return null; + case BrighterInterfaceKind.Transform: + if (type.IsGenericType) unsupportedGenericMapperOrTransform = true; + else seenTransform = true; + return null; + case BrighterInterfaceKind.SyncHandler: + return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType!); + case BrighterInterfaceKind.AsyncHandler: + return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType!); + case BrighterInterfaceKind.Mapper: + return MakeMapperEntry(DiscoveredKind.Mapper, type, requestType!, ref unsupportedGenericMapperOrTransform); + case BrighterInterfaceKind.AsyncMapper: + return MakeMapperEntry(DiscoveredKind.AsyncMapper, type, requestType!, ref unsupportedGenericMapperOrTransform); + default: + return null; } + } + /// Classify a single interface the type implements as a Brighter role (or None). + private static BrighterInterfaceKind ClassifyInterface(INamedTypeSymbol iface, MarkerSymbols markers, out ITypeSymbol? requestType) + { + requestType = null; + if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync)) + return BrighterInterfaceKind.Transform; if (!iface.IsGenericType || iface.TypeArguments.Length != 1) - return null; - - var def = iface.OriginalDefinition; - var requestType = iface.TypeArguments[0]; + return BrighterInterfaceKind.None; - if (Same(def, markers.HandleRequests)) - return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType); - if (Same(def, markers.HandleRequestsAsync)) - return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType); - if (Same(def, markers.MessageMapper)) - { - if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; } - return new DiscoveredEntry(DiscoveredKind.Mapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); - } - if (Same(def, markers.MessageMapperAsync)) - { - if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; } - return new DiscoveredEntry(DiscoveredKind.AsyncMapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); - } + requestType = iface.TypeArguments[0]; + return GenericInterfaceKind(iface.OriginalDefinition, markers); + } - return null; + private static BrighterInterfaceKind GenericInterfaceKind(INamedTypeSymbol def, MarkerSymbols markers) + { + if (Same(def, markers.HandleRequests)) return BrighterInterfaceKind.SyncHandler; + if (Same(def, markers.HandleRequestsAsync)) return BrighterInterfaceKind.AsyncHandler; + if (Same(def, markers.MessageMapper)) return BrighterInterfaceKind.Mapper; + if (Same(def, markers.MessageMapperAsync)) return BrighterInterfaceKind.AsyncMapper; + return BrighterInterfaceKind.None; } private static DiscoveredEntry MakeHandlerEntry(DiscoveredKind kind, INamedTypeSymbol type, ITypeSymbol requestType) @@ -247,6 +257,17 @@ private static DiscoveredEntry MakeHandlerEntry(DiscoveredKind kind, INamedTypeS return new DiscoveredEntry(kind, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); } + private static DiscoveredEntry? MakeMapperEntry( + DiscoveredKind kind, INamedTypeSymbol type, ITypeSymbol requestType, ref bool unsupportedGenericMapperOrTransform) + { + if (type.IsGenericType) + { + unsupportedGenericMapperOrTransform = true; + return null; + } + return new DiscoveredEntry(kind, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); + } + private static bool IsClassifiable(INamedTypeSymbol type) { if (type.TypeKind != TypeKind.Class) @@ -276,22 +297,8 @@ private static bool IsNestedInOpenGeneric(INamedTypeSymbol type) return false; } - private static bool ImplementsAnyBrighterInterface(INamedTypeSymbol type, MarkerSymbols markers) - { - foreach (var iface in type.AllInterfaces) - { - if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync)) - return true; - if (iface.IsGenericType && iface.TypeArguments.Length == 1) - { - var def = iface.OriginalDefinition; - if (Same(def, markers.HandleRequests) || Same(def, markers.HandleRequestsAsync) - || Same(def, markers.MessageMapper) || Same(def, markers.MessageMapperAsync)) - return true; - } - } - return false; - } + private static bool ImplementsAnyBrighterInterface(INamedTypeSymbol type, MarkerSymbols markers) => + type.AllInterfaces.Any(iface => ClassifyInterface(iface, markers, out _) != BrighterInterfaceKind.None); private static string BuildHintName(INamedTypeSymbol containingType, string methodName) { @@ -338,14 +345,16 @@ private static string UnboundGenericName(INamedTypeSymbol type) private static bool Same(ISymbol? a, ISymbol? b) => SymbolEqualityComparer.Default.Equals(a, b); - private static string AccessibilityModifier(Accessibility accessibility) => accessibility switch + private static readonly Dictionary s_accessibilityModifiers = new() { - Accessibility.Public => "public", - Accessibility.Internal => "internal", - Accessibility.Private => "private", - Accessibility.Protected => "protected", - Accessibility.ProtectedOrInternal => "protected internal", - Accessibility.ProtectedAndInternal => "private protected", - _ => "internal" + [Accessibility.Public] = "public", + [Accessibility.Internal] = "internal", + [Accessibility.Private] = "private", + [Accessibility.Protected] = "protected", + [Accessibility.ProtectedOrInternal] = "protected internal", + [Accessibility.ProtectedAndInternal] = "private protected", }; + + private static string AccessibilityModifier(Accessibility accessibility) => + s_accessibilityModifiers.TryGetValue(accessibility, out var modifier) ? modifier : "internal"; } From c63b750987794b9e6c27a928d388459205ae50fe Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 5 Jun 2026 09:53:28 +0100 Subject: [PATCH 15/20] fix: address third review batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default-mapper parity: always emit builder.MapperRegistry(...) (even with no discovered mappers) so the default message mapper is registered via EnsureDefaultMessageMapperIsRegistered, matching AutoFromAssemblies — it was silently absent on a handlers-only assembly. - Gate discovery diagnostics (BRGEN005/006) on the generator actually being active (auto-registration on, or a manual method present), so a transitive analyzer reference that generates nothing doesn't warn about a consumer's generic / nested-in-open-generic types. - Open-generic handler registry cast now uses 'as ... ?? throw InvalidOperationException' with a clear message instead of a hard cast, symmetric with the Transforms extension. - CodeScene: replace TryClassifyInterface's two ref params with a tuple return (5 args -> 3) via a shared classifier; drop WriteMappers' early return. - Document the Roslyn-floor / RS2002 entanglement in ADR 0062 (the version pin must be done together with the deferred packaging work). All 34 tests pass; HelloWorld sample builds clean. Co-Authored-By: Claude Opus 4.8 --- ...2-source-generated-handler-registration.md | 7 +++- .../BrighterRegistrationsGenerator.cs | 41 ++++++++++++------- .../Paramore.Brighter.SourceGenerators.csproj | 8 ++++ .../RegistrationWriter.cs | 8 ++-- .../SemanticModelReader.cs | 41 ++++++++----------- .../AutoRegistrationTests.cs | 34 +++++++++++++++ .../BrighterRegistrationsGeneratorTests.cs | 36 ++++++++++++++-- .../RegistrationWriterTests.cs | 12 +++--- 8 files changed, 136 insertions(+), 51 deletions(-) diff --git a/docs/adr/0062-source-generated-handler-registration.md b/docs/adr/0062-source-generated-handler-registration.md index 7abacb90ff..5e287a27db 100644 --- a/docs/adr/0062-source-generated-handler-registration.md +++ b/docs/adr/0062-source-generated-handler-registration.md @@ -184,7 +184,12 @@ These are accepted gaps, tracked rather than fixed in this change: - **Roslyn version floor.** The generator references `Microsoft.CodeAnalysis.CSharp` at the repo-pinned version. Since a generator's referenced Roslyn version sets the *minimum* compiler/SDK a consumer needs, before packaging it should be pinned to the lowest version providing the APIs - used (`ForAttributeWithMetadataName`, 4.3.1+) to maximise the range of consuming SDKs. + used (`ForAttributeWithMetadataName`, 4.3.1+) to maximise the range of consuming SDKs. Note this + pin is entangled with diagnostics: pinning to 4.3.1 pulls in an older + `Microsoft.CodeAnalysis.Analyzers` whose `RS2002` requires the `BRGEN0xx` IDs to be declared on a + `DiagnosticAnalyzer.SupportedDiagnostics`, which an `IIncrementalGenerator` has no equivalent of — + so the floor pin must be done together with resolving that (e.g. a companion analyzer declaring + the descriptors, or suppressing `RS2002`). - **Public surface.** The reader/writer/model types are `public` for testability; for a dev-dependency generator they could be `internal` + `InternalsVisibleTo`. Harmless either way. diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index 3360fdc4ed..a3e2ec04be 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -123,8 +123,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new EquatableArray( batches.SelectMany(static b => (IEnumerable)b.Diagnostics).Distinct())); - context.RegisterSourceOutput(discoveryDiagnostics, static (spc, diagnostics) => + // True when the compilation hand-writes at least one valid [BrighterRegistrations] method. + // Used to suppress the auto class so the two paths can't both register (double registration, + // or an ambiguous AddFromThisAssembly call when the manual method shares the name). + var hasManualRegistration = methodCandidates + .Collect() + .Select(static (candidates, _) => candidates.Any(static c => c.Method is not null)); + + var autoEnabled = context.AnalyzerConfigOptionsProvider + .Select(static (provider, _) => + provider.GlobalOptions.TryGetValue(AutoRegistrationProperty, out var v) + && bool.TryParse(v, out var b) + && b); + + // The generator only emits registrations when auto-registration is on or a manual method is + // present. Gate discovery diagnostics (BRGEN005/006) on that, so a transitive analyzer + // reference that generates nothing doesn't warn about the consumer's generic / + // nested-in-open-generic types. + var generatorActive = autoEnabled.Combine(hasManualRegistration) + .Select(static (pair, _) => pair.Left || pair.Right); + + context.RegisterSourceOutput(discoveryDiagnostics.Combine(generatorActive), static (spc, pair) => { + var (diagnostics, active) = pair; + if (!active) + return; foreach (var info in diagnostics) spc.ReportDiagnostic(ToDiagnostic(info)); }); @@ -149,14 +172,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); }); - // True when the compilation hand-writes at least one valid [BrighterRegistrations] method. - // Used to suppress the auto class so the two paths can't both register (double registration, - // or an ambiguous AddFromThisAssembly call when the manual method shares the name). - var hasManualRegistration = methodCandidates - .Collect() - .Select(static (candidates, _) => candidates.Any(static c => c.Method is not null)); - - RegisterAutoRegistration(context, discovered, hasManualRegistration); + RegisterAutoRegistration(context, discovered, autoEnabled, hasManualRegistration); } /// @@ -168,14 +184,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private static void RegisterAutoRegistration( IncrementalGeneratorInitializationContext context, IncrementalValueProvider> discovered, + IncrementalValueProvider autoEnabled, IncrementalValueProvider hasManualRegistration) { - var autoEnabled = context.AnalyzerConfigOptionsProvider - .Select(static (provider, _) => - provider.GlobalOptions.TryGetValue(AutoRegistrationProperty, out var v) - && bool.TryParse(v, out var b) - && b); - var brighterAvailable = context.CompilationProvider .Select(static (c, _) => MarkerSymbols.Resolve(c).IsValid); diff --git a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj index 5f5b4924ca..caa9548642 100644 --- a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj +++ b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj @@ -16,6 +16,14 @@ + diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index 313f6dbb15..05e6b1bc24 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -148,7 +148,7 @@ private static void WriteOpenGenericHandlers(CodeWriter code, EquatableArray sync, EquatableArray async) { - if (sync.Count == 0 && async.Count == 0) - return; - + // Always emit MapperRegistry, even with no discovered mappers: the call registers Brighter's + // default message mapper (EnsureDefaultMessageMapperIsRegistered), matching the parity goal + // with AutoFromAssemblies, which would otherwise be lost on a handlers-only assembly. code.WriteLine($"{paramName}.MapperRegistry(r =>"); code.StartBlock(); diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index 1731a335fc..eafba6b3b1 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -181,9 +181,11 @@ private static void ClassifyEntries( foreach (var iface in type.AllInterfaces) { - var entry = TryClassifyInterface(type, iface, markers, ref seenTransform, ref unsupportedGenericMapperOrTransform); + var (entry, isTransform, isUnsupported) = TryClassifyInterface(type, iface, markers); if (entry is not null) entries.Add(entry); + seenTransform |= isTransform; + unsupportedGenericMapperOrTransform |= isUnsupported; } // seenTransform is only set on the non-generic branch, so it already implies !IsGenericType. @@ -202,29 +204,27 @@ private static void ClassifyEntries( private enum BrighterInterfaceKind { None, SyncHandler, AsyncHandler, Mapper, AsyncMapper, Transform } - private static DiscoveredEntry? TryClassifyInterface( + // Returns the discovered entry (if any) plus flags the caller folds into its running state, so + // the per-interface classification stays a pure function of (type, iface) with no ref plumbing. + private static (DiscoveredEntry? Entry, bool IsTransform, bool IsUnsupportedGeneric) TryClassifyInterface( INamedTypeSymbol type, INamedTypeSymbol iface, - MarkerSymbols markers, - ref bool seenTransform, - ref bool unsupportedGenericMapperOrTransform) + MarkerSymbols markers) { switch (ClassifyInterface(iface, markers, out var requestType)) { case BrighterInterfaceKind.Transform: - if (type.IsGenericType) unsupportedGenericMapperOrTransform = true; - else seenTransform = true; - return null; + return type.IsGenericType ? (null, false, true) : (null, true, false); case BrighterInterfaceKind.SyncHandler: - return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType!); + return (MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType!), false, false); case BrighterInterfaceKind.AsyncHandler: - return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType!); + return (MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType!), false, false); case BrighterInterfaceKind.Mapper: - return MakeMapperEntry(DiscoveredKind.Mapper, type, requestType!, ref unsupportedGenericMapperOrTransform); + return MapperClassification(DiscoveredKind.Mapper, type, requestType!); case BrighterInterfaceKind.AsyncMapper: - return MakeMapperEntry(DiscoveredKind.AsyncMapper, type, requestType!, ref unsupportedGenericMapperOrTransform); + return MapperClassification(DiscoveredKind.AsyncMapper, type, requestType!); default: - return null; + return (null, false, false); } } @@ -257,16 +257,11 @@ private static DiscoveredEntry MakeHandlerEntry(DiscoveredKind kind, INamedTypeS return new DiscoveredEntry(kind, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); } - private static DiscoveredEntry? MakeMapperEntry( - DiscoveredKind kind, INamedTypeSymbol type, ITypeSymbol requestType, ref bool unsupportedGenericMapperOrTransform) - { - if (type.IsGenericType) - { - unsupportedGenericMapperOrTransform = true; - return null; - } - return new DiscoveredEntry(kind, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false); - } + private static (DiscoveredEntry? Entry, bool IsTransform, bool IsUnsupportedGeneric) MapperClassification( + DiscoveredKind kind, INamedTypeSymbol type, ITypeSymbol requestType) => + type.IsGenericType + ? (null, false, true) + : (new DiscoveredEntry(kind, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false), false, false); private static bool IsClassifiable(INamedTypeSymbol type) { diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs index a98ee7896a..9c7eefd637 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/AutoRegistrationTests.cs @@ -145,6 +145,40 @@ public void PropertyTrue_WithManualRegistrationMethod_SuppressesAutoClass() Assert.Contains(single.Diagnostics, d => d.Id == "BRGEN007"); } + [Fact] + public void PropertyTrue_WithInvalidManualMethod_StillGeneratesAutoClass() + { + // Only a *valid* [BrighterRegistrations] method suppresses the auto class; an invalid one + // (which produces its own BRGEN diagnostic and no implementation) does not. + const string source = """ + using Paramore.Brighter; + using Paramore.Brighter.Extensions.DependencyInjection; + + namespace App; + + public class GreetingCommand : Command + { + public GreetingCommand() : base(System.Guid.NewGuid()) { } + } + + public class GreetingHandler : RequestHandler + { + public override GreetingCommand Handle(GreetingCommand command) => base.Handle(command); + } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial void NotAValidSignature(); + } + """; + var single = Run(source, "true").Results.Single(); + + Assert.Contains( + single.GeneratedSources, + g => g.HintName == "BrighterAssemblyRegistrations__AddFromThisAssembly.g.cs"); + } + private sealed class StubOptionsProvider : AnalyzerConfigOptionsProvider { private readonly StubOptions _global; diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs index 86fb67d83d..24c88fb82b 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/BrighterRegistrationsGeneratorTests.cs @@ -78,6 +78,9 @@ public static partial class Registrations { r.Register(); }); + builder.MapperRegistry(r => + { + }); """)); await test.RunAsync(); @@ -113,7 +116,11 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(Registration("")); + test.TestState.GeneratedSources.Add(Registration(""" + builder.MapperRegistry(r => + { + }); + """)); await test.RunAsync(); } @@ -151,6 +158,9 @@ public static partial class Registrations { r.RegisterAsync(); }); + builder.MapperRegistry(r => + { + }); """)); await test.RunAsync(); @@ -261,6 +271,9 @@ public void Helper() { } { r.Register(); }); + builder.MapperRegistry(r => + { + }); """)); await test.RunAsync(); @@ -302,7 +315,11 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(Registration("")); + test.TestState.GeneratedSources.Add(Registration(""" + builder.MapperRegistry(r => + { + }); + """)); await test.RunAsync(); } @@ -335,7 +352,11 @@ public static partial class Registrations var test = MakeTest(); test.TestState.Sources.Add(userCode); test.TestState.GeneratedSources.Add(AttributeFile()); - test.TestState.GeneratedSources.Add(Registration("")); + test.TestState.GeneratedSources.Add(Registration(""" + builder.MapperRegistry(r => + { + }); + """)); test.TestState.ExpectedDiagnostics.Add( DiagnosticResult.CompilerWarning("BRGEN005").WithSpan(8, 14, 8, 24).WithArguments("global::App.OpenMapper")); @@ -535,6 +556,9 @@ public partial class SplitHandler : RequestHandler { r.Register(); }); + builder.MapperRegistry(r => + { + }); """)); await test.RunAsync(); @@ -598,6 +622,12 @@ public partial class OpenMapper : IAmAMessageMapper public Message MapToMessage(GreetingEvent request, Publication publication) => new(); public GreetingEvent MapToRequest(Message message) => new(); } + + public static partial class Registrations + { + [BrighterRegistrations] + public static partial IBrighterBuilder AddFromThisAssembly(this IBrighterBuilder builder); + } """, """ namespace App; diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs index 90552567aa..1e6993ff50 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs @@ -49,8 +49,10 @@ public void EmptyModel_EmitsScaffoldAndReturnsBuilder() Assert.Contains("public static partial global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFromThisAssembly(this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder)", output); Assert.Contains("return builder;", output); Assert.DoesNotContain(".Handlers(", output); - Assert.DoesNotContain(".MapperRegistry(", output); Assert.DoesNotContain(".Transforms(", output); + // MapperRegistry is always emitted so the default message mapper is registered, matching + // AutoFromAssemblies even when no mappers are discovered. + Assert.Contains("builder.MapperRegistry(r =>", output); } [Fact] @@ -78,7 +80,7 @@ public void OpenGenericHandler_EmitsEnsureHandlerIsRegistered() var output = RegistrationWriter.Write(model); - Assert.Contains("var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;", output); + Assert.Contains("var registry = r as global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry ?? throw new global::System.InvalidOperationException(", output); Assert.Contains("registry.EnsureHandlerIsRegistered(typeof(global::MyApp.PolicyHandler<>));", output); } @@ -95,9 +97,9 @@ public void MixedClosedAndOpenHandlers_EmitsCastOnlyOnce() Assert.Contains("r.Register();", output); Assert.Contains("registry.EnsureHandlerIsRegistered(typeof(global::MyApp.PolicyHandler<>));", output); - // Cast appears exactly once. - var castOccurrences = output.Split(new[] { "ServiceCollectionSubscriberRegistry)r" }, System.StringSplitOptions.None).Length - 1; - Assert.Equal(1, castOccurrences); + // The registry is resolved exactly once, even with multiple open generics. + var registryResolves = output.Split(new[] { "var registry =" }, System.StringSplitOptions.None).Length - 1; + Assert.Equal(1, registryResolves); } [Fact] From cfc81569cf9372b7ba3fd079514fedeeffacc9bd Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 5 Jun 2026 12:23:04 +0100 Subject: [PATCH 16/20] refactor: pin Roslyn to 4.8 and clear remaining CodeScene findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin Microsoft.CodeAnalysis.CSharp to 4.8.0 via VersionOverride (first .NET 8 Roslyn, the oldest in-support modern .NET) so the generator loads on that SDK and newer. 4.8.0's bundled analyzers don't trip the RS2002 that 4.3.1 did. - RegistrationModel now composes MethodTarget instead of duplicating its 12 fields (17 ctor args -> 6), removing the duplication and the Excess-Arguments finding. - DescriptorFor: switch -> dictionary lookup (cyclomatic 9 -> ~2). - IsClassifiable: split the 3-way || into single-condition guards (no complex conditional). - ClassifyEntries: extract TryReportNestedInOpenGeneric so it no longer carries a second block of nested conditionals (Bumpy Road). Pure structural change — generated output byte-identical; all 34 tests pass; generator and HelloWorld sample build clean against Roslyn 4.8.0. Co-Authored-By: Claude Opus 4.8 --- ...2-source-generated-handler-registration.md | 12 ++---- .../BrighterRegistrationsGenerator.cs | 24 ++++++----- .../Model/RegistrationModel.cs | 35 ++++------------ .../Paramore.Brighter.SourceGenerators.csproj | 10 ++--- .../RegistrationWriter.cs | 39 ++++++++--------- .../SemanticModelReader.cs | 31 ++++++++++---- .../RegistrationWriterTests.cs | 42 ++++++++++--------- 7 files changed, 93 insertions(+), 100 deletions(-) diff --git a/docs/adr/0062-source-generated-handler-registration.md b/docs/adr/0062-source-generated-handler-registration.md index 5e287a27db..91bcf172f7 100644 --- a/docs/adr/0062-source-generated-handler-registration.md +++ b/docs/adr/0062-source-generated-handler-registration.md @@ -147,6 +147,9 @@ types (double registration) or collide on the `AddFromThisAssembly(this IBrighte because a mapper/transform was missing. - **Trimming / AOT friendly and lower startup cost** for projects that rely on generation instead of scanning. +- **Broad SDK compatibility.** The generator assembly is built against Roslyn 4.8.0 (the first + .NET 8 Roslyn, the oldest in-support modern .NET) via a `VersionOverride`, so it loads on that SDK + and newer rather than requiring the repo-wide (latest) Roslyn. - **Behaviourally identical downstream.** Generated registrations flow through the same registries, so `ValidatePipelines` / `DescribePipelines` behave the same. @@ -181,15 +184,6 @@ These are accepted gaps, tracked rather than fixed in this change: not yet exercised by a real package consumer — only the in-repo `ProjectReference` / `OutputItemType="Analyzer"` path (used by the sample). Shipping should follow the existing `Paramore.Brighter.Analyzer.Package` pattern. -- **Roslyn version floor.** The generator references `Microsoft.CodeAnalysis.CSharp` at the - repo-pinned version. Since a generator's referenced Roslyn version sets the *minimum* compiler/SDK - a consumer needs, before packaging it should be pinned to the lowest version providing the APIs - used (`ForAttributeWithMetadataName`, 4.3.1+) to maximise the range of consuming SDKs. Note this - pin is entangled with diagnostics: pinning to 4.3.1 pulls in an older - `Microsoft.CodeAnalysis.Analyzers` whose `RS2002` requires the `BRGEN0xx` IDs to be declared on a - `DiagnosticAnalyzer.SupportedDiagnostics`, which an `IIncrementalGenerator` has no equivalent of — - so the floor pin must be done together with resolving that (e.g. a companion analyzer declaring - the descriptors, or suppressing `RS2002`). - **Public surface.** The reader/writer/model types are `public` for testability; for a dev-dependency generator they could be `internal` + `InternalsVisibleTo`. Harmless either way. diff --git a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs index a3e2ec04be..46b12b9f4f 100644 --- a/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs +++ b/src/Paramore.Brighter.SourceGenerators/BrighterRegistrationsGenerator.cs @@ -169,7 +169,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return; var model = RegistrationModel.From(candidate.Method, entries); - spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); + spc.AddSource(model.Target.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); }); RegisterAutoRegistration(context, discovered, autoEnabled, hasManualRegistration); @@ -210,7 +210,7 @@ private static void RegisterAutoRegistration( var target = BuildAutoTarget(); var model = RegistrationModel.From(target, entries); - spc.AddSource(model.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); + spc.AddSource(model.Target.HintName, SourceText.From(RegistrationWriter.Write(model), Encoding.UTF8)); }); } @@ -247,14 +247,18 @@ private static Diagnostic ToDiagnostic(DiagnosticInfo info) => Diagnostic.Create info.Location?.ToLocation() ?? Location.None, info.Argument); - private static DiagnosticDescriptor DescriptorFor(string id) => id switch + private static readonly Dictionary s_descriptorsById = new() { - "BRGEN001" => Diagnostics.MustBePartial, - "BRGEN002" => Diagnostics.MustBeStatic, - "BRGEN003" => Diagnostics.WrongReturnType, - "BRGEN004" => Diagnostics.WrongSignature, - "BRGEN005" => Diagnostics.GenericMapperOrTransformIgnored, - "BRGEN006" => Diagnostics.NestedInOpenGeneric, - _ => throw new System.InvalidOperationException($"Unknown Brighter diagnostic id '{id}' — DescriptorFor needs updating."), + [Diagnostics.MustBePartial.Id] = Diagnostics.MustBePartial, + [Diagnostics.MustBeStatic.Id] = Diagnostics.MustBeStatic, + [Diagnostics.WrongReturnType.Id] = Diagnostics.WrongReturnType, + [Diagnostics.WrongSignature.Id] = Diagnostics.WrongSignature, + [Diagnostics.GenericMapperOrTransformIgnored.Id] = Diagnostics.GenericMapperOrTransformIgnored, + [Diagnostics.NestedInOpenGeneric.Id] = Diagnostics.NestedInOpenGeneric, }; + + private static DiagnosticDescriptor DescriptorFor(string id) => + s_descriptorsById.TryGetValue(id, out var descriptor) + ? descriptor + : throw new System.InvalidOperationException($"Unknown Brighter diagnostic id '{id}' — DescriptorFor needs updating."); } diff --git a/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs index f7908550a6..72776d9131 100644 --- a/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs +++ b/src/Paramore.Brighter.SourceGenerators/Model/RegistrationModel.cs @@ -27,27 +27,17 @@ THE SOFTWARE. */ namespace Paramore.Brighter.SourceGenerators.Model; /// -/// Pure-data description of what the generator should emit for a single registration method. -/// Deliberately free of Roslyn types so the writer can be unit-tested without a Compilation. +/// Pure-data description of what the generator should emit for a single registration method: the +/// method/type plus the discovered registrations. Deliberately free of Roslyn +/// types so the writer can be unit-tested without a Compilation. /// public sealed record RegistrationModel( - string? Namespace, - string ContainingTypeAccessibility, - string ContainingTypeName, - bool ContainingTypeIsStatic, - string MethodAccessibility, - string MethodName, - string ReturnTypeFullyQualified, - string ParameterTypeFullyQualified, - string ParameterName, - bool IsExtensionMethod, + MethodTarget Target, EquatableArray Handlers, EquatableArray AsyncHandlers, EquatableArray Mappers, EquatableArray AsyncMappers, - EquatableArray Transforms, - string HintName, - bool IsPartial = true) + EquatableArray Transforms) { /// /// Assemble a model from a per-method target and the flat list of discovered registration @@ -84,23 +74,12 @@ public static RegistrationModel From(MethodTarget target, EquatableArray(sync), new EquatableArray(async), new EquatableArray(mappers), new EquatableArray(asyncMappers), - new EquatableArray(transforms), - target.HintName, - target.IsPartial); + new EquatableArray(transforms)); } } diff --git a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj index caa9548642..d86cb6a6f8 100644 --- a/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj +++ b/src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj @@ -18,13 +18,11 @@ - + diff --git a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs index 05e6b1bc24..d0af3bf696 100644 --- a/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs +++ b/src/Paramore.Brighter.SourceGenerators/RegistrationWriter.cs @@ -47,10 +47,10 @@ public static string Write(RegistrationModel model) code.WriteLine("#nullable enable"); code.WriteLineNoTabs(string.Empty); - var hasNamespace = !string.IsNullOrEmpty(model.Namespace); + var hasNamespace = !string.IsNullOrEmpty(model.Target.Namespace); if (hasNamespace) { - code.WriteLine($"namespace {model.Namespace}"); + code.WriteLine($"namespace {model.Target.Namespace}"); code.StartBlock(); } else @@ -71,41 +71,42 @@ public static string Write(RegistrationModel model) private static void WriteContainingType(CodeWriter code, RegistrationModel model) { - code.WriteLine(ContainingTypeDeclaration(model)); + var target = model.Target; + code.WriteLine(ContainingTypeDeclaration(target)); code.StartBlock(); code.WriteLine(GeneratedSource.GeneratedCodeAttribute); - code.WriteLine(MethodSignature(model)); + code.WriteLine(MethodSignature(target)); code.StartBlock(); - WriteHandlers(code, model.ParameterName, model.Handlers, isAsync: false); - WriteHandlers(code, model.ParameterName, model.AsyncHandlers, isAsync: true); - WriteMappers(code, model.ParameterName, model.Mappers, model.AsyncMappers); - WriteTransforms(code, model.ParameterName, model.Transforms); + WriteHandlers(code, target.ParameterName, model.Handlers, isAsync: false); + WriteHandlers(code, target.ParameterName, model.AsyncHandlers, isAsync: true); + WriteMappers(code, target.ParameterName, model.Mappers, model.AsyncMappers); + WriteTransforms(code, target.ParameterName, model.Transforms); - code.WriteLine($"return {model.ParameterName};"); + code.WriteLine($"return {target.ParameterName};"); code.EndBlock(); // method code.EndBlock(); // containing type } - private static string ContainingTypeDeclaration(RegistrationModel model) + private static string ContainingTypeDeclaration(MethodTarget target) { - var modifiers = (model.ContainingTypeIsStatic ? "static " : string.Empty) - + (model.IsPartial ? "partial " : string.Empty); - return $"{model.ContainingTypeAccessibility} {modifiers}class {model.ContainingTypeName}"; + var modifiers = (target.ContainingTypeIsStatic ? "static " : string.Empty) + + (target.IsPartial ? "partial " : string.Empty); + return $"{target.ContainingTypeAccessibility} {modifiers}class {target.ContainingTypeName}"; } - private static string MethodSignature(RegistrationModel model) + private static string MethodSignature(MethodTarget target) { var sb = new StringBuilder(); - sb.Append(model.MethodAccessibility).Append(" static "); - if (model.IsPartial) + sb.Append(target.MethodAccessibility).Append(" static "); + if (target.IsPartial) sb.Append("partial "); - sb.Append(model.ReturnTypeFullyQualified).Append(' ').Append(model.MethodName).Append('('); - if (model.IsExtensionMethod) + sb.Append(target.ReturnTypeFullyQualified).Append(' ').Append(target.MethodName).Append('('); + if (target.IsExtensionMethod) sb.Append("this "); - sb.Append(model.ParameterTypeFullyQualified).Append(' ').Append(model.ParameterName).Append(')'); + sb.Append(target.ParameterTypeFullyQualified).Append(' ').Append(target.ParameterName).Append(')'); return sb.ToString(); } diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index eafba6b3b1..6a44f725df 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -166,15 +166,8 @@ private static void ClassifyEntries( // arguments at the registration call site, so any registration we emit would reference // unbound type parameters and fail to compile. Surface it as BRGEN006 instead — and bail // before classifying, because building entries for such a type would itself misfire. - if (IsNestedInOpenGeneric(type)) - { - if (ImplementsAnyBrighterInterface(type, markers)) - diagnostics.Add(new DiagnosticInfo( - Diagnostics.NestedInOpenGeneric.Id, - LocationInfo.From(type.Locations.FirstOrDefault()), - FullyQualified(type))); + if (TryReportNestedInOpenGeneric(type, markers, diagnostics)) return; - } var seenTransform = false; var unsupportedGenericMapperOrTransform = false; @@ -267,7 +260,11 @@ private static bool IsClassifiable(INamedTypeSymbol type) { if (type.TypeKind != TypeKind.Class) return false; - if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) + if (type.IsAbstract) + return false; + if (type.IsImplicitClass) + return false; + if (type.IsAnonymousType) return false; return IsReachableFromGeneratedCode(type); } @@ -282,6 +279,22 @@ private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) return true; } + // Reports BRGEN006 for a Brighter type nested in an open generic and returns true (telling the + // caller to stop); returns false for everything else. Kept separate so ClassifyEntries doesn't + // carry a second block of nested conditionals. + private static bool TryReportNestedInOpenGeneric( + INamedTypeSymbol type, MarkerSymbols markers, List diagnostics) + { + if (!IsNestedInOpenGeneric(type)) + return false; + if (ImplementsAnyBrighterInterface(type, markers)) + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NestedInOpenGeneric.Id, + LocationInfo.From(type.Locations.FirstOrDefault()), + FullyQualified(type))); + return true; + } + private static bool IsNestedInOpenGeneric(INamedTypeSymbol type) { for (var outer = type.ContainingType; outer is not null; outer = outer.ContainingType) diff --git a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs index 1e6993ff50..beef0c9c6a 100644 --- a/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs +++ b/tests/Paramore.Brighter.SourceGenerators.Tests/RegistrationWriterTests.cs @@ -10,24 +10,28 @@ private static RegistrationModel EmptyModel( EquatableArray? asyncHandlers = null, EquatableArray? mappers = null, EquatableArray? asyncMappers = null, - EquatableArray? transforms = null) => + EquatableArray? transforms = null, + string? @namespace = "MyApp", + bool containingTypeIsStatic = true, + bool isExtensionMethod = true) => new( - Namespace: "MyApp", - ContainingTypeAccessibility: "public", - ContainingTypeName: "Registrations", - ContainingTypeIsStatic: true, - MethodAccessibility: "public", - MethodName: "AddFromThisAssembly", - ReturnTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", - ParameterTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", - ParameterName: "builder", - IsExtensionMethod: true, - Handlers: handlers ?? EquatableArray.Empty, - AsyncHandlers: asyncHandlers ?? EquatableArray.Empty, - Mappers: mappers ?? EquatableArray.Empty, - AsyncMappers: asyncMappers ?? EquatableArray.Empty, - Transforms: transforms ?? EquatableArray.Empty, - HintName: "MyApp_Registrations__AddFromThisAssembly.g.cs"); + new MethodTarget( + Namespace: @namespace, + ContainingTypeAccessibility: "public", + ContainingTypeName: "Registrations", + ContainingTypeIsStatic: containingTypeIsStatic, + MethodAccessibility: "public", + MethodName: "AddFromThisAssembly", + ReturnTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", + ParameterTypeFullyQualified: "global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder", + ParameterName: "builder", + IsExtensionMethod: isExtensionMethod, + HintName: "MyApp_Registrations__AddFromThisAssembly.g.cs"), + handlers ?? EquatableArray.Empty, + asyncHandlers ?? EquatableArray.Empty, + mappers ?? EquatableArray.Empty, + asyncMappers ?? EquatableArray.Empty, + transforms ?? EquatableArray.Empty); [Fact] public void EmitsGeneratedBannerAndGeneratedCodeAttribute() @@ -153,7 +157,7 @@ public void Transforms_EmitsTransformsCallback() [Fact] public void GlobalNamespace_OmitsNamespaceWrapper() { - var model = EmptyModel() with { Namespace = null }; + var model = EmptyModel(@namespace: null); var output = RegistrationWriter.Write(model); @@ -164,7 +168,7 @@ public void GlobalNamespace_OmitsNamespaceWrapper() [Fact] public void NonStaticContainingType_EmitsPartialClassWithoutStatic() { - var model = EmptyModel() with { ContainingTypeIsStatic = false, IsExtensionMethod = false }; + var model = EmptyModel(containingTypeIsStatic: false, isExtensionMethod: false); var output = RegistrationWriter.Write(model); From 0c4c60e1e9433ea0ac303a6f44cf6c34470e55b5 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 5 Jun 2026 12:48:54 +0100 Subject: [PATCH 17/20] refactor: revert two metric-driven changes that hurt readability Undo two changes made purely to satisfy CodeScene thresholds where the original code was clearer: - AccessibilityModifier: back to a switch expression (a closed enum -> string map) instead of a static Dictionary + TryGetValue. - IsClassifiable: back to a single 'IsAbstract || IsImplicitClass || IsAnonymousType' guard instead of three separate ifs. These reintroduce the 'Complex Conditional' (IsClassifiable) and contribute to 'Overall Code Complexity' (SemanticModelReader) findings, which are false positives on this kind of code and should be suppressed in CodeScene rather than worked around. The genuine improvements from the complexity pass (RegistrationModel composing MethodTarget, the shared ClassifyInterface dedup) are kept. Output byte-identical; all 34 tests pass. Co-Authored-By: Claude Opus 4.8 --- .../SemanticModelReader.cs | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs index 6a44f725df..30cfaebe4f 100644 --- a/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs +++ b/src/Paramore.Brighter.SourceGenerators/SemanticModelReader.cs @@ -260,11 +260,7 @@ private static bool IsClassifiable(INamedTypeSymbol type) { if (type.TypeKind != TypeKind.Class) return false; - if (type.IsAbstract) - return false; - if (type.IsImplicitClass) - return false; - if (type.IsAnonymousType) + if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) return false; return IsReachableFromGeneratedCode(type); } @@ -353,16 +349,14 @@ private static string UnboundGenericName(INamedTypeSymbol type) private static bool Same(ISymbol? a, ISymbol? b) => SymbolEqualityComparer.Default.Equals(a, b); - private static readonly Dictionary s_accessibilityModifiers = new() + private static string AccessibilityModifier(Accessibility accessibility) => accessibility switch { - [Accessibility.Public] = "public", - [Accessibility.Internal] = "internal", - [Accessibility.Private] = "private", - [Accessibility.Protected] = "protected", - [Accessibility.ProtectedOrInternal] = "protected internal", - [Accessibility.ProtectedAndInternal] = "private protected", + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "internal" }; - - private static string AccessibilityModifier(Accessibility accessibility) => - s_accessibilityModifiers.TryGetValue(accessibility, out var modifier) ? modifier : "internal"; } From db546d043850db38348b838ffcb44fe37d9ede46 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 12 Jul 2026 13:50:36 +0100 Subject: [PATCH 18/20] docs: reshape ADR 0062 around a RegistrationCatalog data type Registration artefact changes from generated imperative IBrighterBuilder method bodies to an inert catalog in core, applied by a new AddRegistrations extension in the DI package. Records the multi-assembly / domain-project motivation, the declared partial-class holder form, and moves the original design to Alternatives as superseded. Co-Authored-By: Claude Fable 5 --- ...2-source-generated-handler-registration.md | 332 +++++++++++++----- 1 file changed, 247 insertions(+), 85 deletions(-) diff --git a/docs/adr/0062-source-generated-handler-registration.md b/docs/adr/0062-source-generated-handler-registration.md index 91bcf172f7..581a151c22 100644 --- a/docs/adr/0062-source-generated-handler-registration.md +++ b/docs/adr/0062-source-generated-handler-registration.md @@ -1,6 +1,8 @@ # 62. Source-Generated Handler, Mapper and Transform Registration -Date: 2026-06-04 +Date: 2026-06-04 (amended 2026-07-12: registration artefact reshaped from generated imperative +method bodies to a `RegistrationCatalog` data type — see Alternative 5 for the original shape and +why it was superseded) ## Status @@ -40,14 +42,22 @@ assemblies the host happens to have loaded, or the explicit list passed to 4. **Reflection has a cost.** Assembly scanning at startup is incompatible with trimming / Native AOT and adds startup latency proportional to the assembly surface scanned. +5. **Handlers rarely live in the host project.** In any layered solution the handlers, mappers and + transforms live in domain/application assemblies, while dependency-injection composition happens + in the host. A registration mechanism that only works inside the composition project — or that + forces domain assemblies to reference the DI stack (`Paramore.Brighter.Extensions.DependencyInjection` + brings `Microsoft.Extensions.DependencyInjection` **and Polly** with it) — does not fit how + Brighter applications are actually structured. Domain assemblies already reference core + `Paramore.Brighter` (that is where `IHandleRequests<>` lives); they should not need more than + that to *describe* their registrations. + ### Requirements Context This is not derived from a `specs/` requirement; it is a standalone architectural decision recorded -retroactively to capture work already implemented on the -`slang25/source-gen-auto-assemblies` branch. Brighter already treats source generation as an -accepted technique for cross-cutting concerns (see [ADR 0026](0026-use-source-generated-logging.md), -source-generated logging) and ships Roslyn analyzers for pipeline validation -([ADR 0037](0037-provide-roslyn-analyzers-for-brighter.md), +for work on the `slang25/source-gen-auto-assemblies` branch. Brighter already treats source +generation as an accepted technique for cross-cutting concerns (see +[ADR 0026](0026-use-source-generated-logging.md), source-generated logging) and ships Roslyn +analyzers for pipeline validation ([ADR 0037](0037-provide-roslyn-analyzers-for-brighter.md), [ADR 0054](0054-roslyn-analyzer-extensions-for-pipeline-validation.md)), so a generator that emits registration code is consistent with the existing direction. @@ -55,8 +65,8 @@ registration code is consistent with the existing direction. - **Must not break existing users.** Assembly scanning and `AutoFromAssemblies` must keep working unchanged; source generation is opt-in and additive. -- **Must register through the existing public surface.** Generated code must go through the same - registries the framework already uses (`IBrighterBuilder.Handlers`, +- **Must register through the existing public surface.** Generated registrations must flow through + the same registries the framework already uses (`IBrighterBuilder.Handlers`, `IBrighterBuilder.MapperRegistry`, and transform registration) so that downstream behaviour — including `ValidatePipelines` / `DescribePipelines` — is identical regardless of how a type was registered. @@ -65,73 +75,147 @@ registration code is consistent with the existing direction. - **Must be incremental.** The generator must participate correctly in Roslyn's incremental pipeline (no semantic-model objects escaping transforms; value-equatable models only) so it does not regress IDE/build performance. +- **Must compose across assemblies.** A solution with several handler-owning projects must be able + to combine their registrations in the host with no name collisions and no per-project DI + dependency. ## Decision -We add a new analyzer-only package, `Paramore.Brighter.SourceGenerators`, containing an -`IIncrementalGenerator` (`BrighterRegistrationsGenerator`) that discovers Brighter types **in the -current compilation** and emits explicit registration code. Registration becomes a compile-time -artefact of the project that owns the types, not a runtime reflection pass over loaded assemblies. +We introduce a three-layer design: an inert **registration catalog** data type in core +`Paramore.Brighter`, an **applier** extension in the DI package, and an analyzer-only package +`Paramore.Brighter.SourceGenerators` whose `IIncrementalGenerator` +(`BrighterRegistrationsGenerator`) discovers Brighter types **in the current compilation** and +emits catalog-construction code. Registration becomes a compile-time artefact of the project that +owns the types, not a runtime reflection pass over loaded assemblies. + +The guiding principle: **the generator emits inert data; all application logic lives in the +library.** Generated code is compiled into consumer assemblies at *their* build time and cannot be +patched by a Brighter release; the less intelligence it contains, the smaller the version-skew +surface between the generator and the runtime libraries. + +### Layer 0 — `RegistrationCatalog` (core `Paramore.Brighter`) + +A pure data type describing an assembly's contribution: handler, mapper and transform +registrations as `(Type, Type)` pairs plus flags. + +```csharp +public sealed class RegistrationCatalog +{ + public IReadOnlyList Handlers { get; } + public IReadOnlyList Mappers { get; } + public IReadOnlyList Transforms { get; } + + public void AddHandler(Type handlerType, Type? requestType, bool isAsync); // requestType null ⇒ open generic + public void AddMapper(Type mapperType, Type requestType, bool isAsync); + public void AddTransform(Type transformType); +} +``` + +- Lives in core because domain assemblies already reference core; describing registrations must + not require the DI package (and its `Microsoft.Extensions.DependencyInjection` / Polly graph). +- Construction is via **additive `Add*` methods, never a growing constructor**: generated code + compiled into shipped consumer packages binds to this surface, so it must only ever evolve by + adding new methods (a new registration kind ⇒ a new `Add*` method), keeping old generated code + binary-compatible. +- `Add*` methods validate their arguments (e.g. `handlerType` implements + `IHandleRequests`), so a defective catalog fails at startup — still far earlier + than the message-receipt-time failure that scanning produces. +- Being data, a catalog is inspectable: a team can unit-test "our catalog contains a mapper for + `OrderPlaced`" without building a container. + +### Layer 1 — `AddRegistrations` (DI package) + +One new extension in `BrighterBuilderExtensions` (off-interface, per the existing pattern, so it +is not a binary-breaking change for `IBrighterBuilder` implementers): + +```csharp +public static IBrighterBuilder AddRegistrations(this IBrighterBuilder builder, params RegistrationCatalog[] catalogs) +``` + +It applies catalogs through the existing public surface: + +- **Handlers** via `builder.Handlers(...)` / `builder.AsyncHandlers(...)` using the existing + non-generic `Add(Type, Type)` on the registries — no `MakeGenericMethod`, so the trimming/AOT + benefit is preserved. Open-generic handlers use `EnsureHandlerIsRegistered` on + `ServiceCollectionSubscriberRegistry` — a coupling that now lives **inside the library**, where + it can evolve with the registry, instead of being frozen into every consumer's generated code. +- **Mappers** via `builder.MapperRegistry(...)` (`Add` / `AddAsync`). `AddRegistrations` always + makes this call, even for a mapper-less catalog, preserving the default-message-mapper guarantee + (`EnsureDefaultMessageMapperIsRegistered`) that `AutoFromAssemblies` provides. +- **Transforms** via the off-interface `BrighterBuilderExtensions.Transforms(...)` extension + (added in this change so transform registration is symmetric with handlers/mappers **without** + a binary-breaking addition to `IBrighterBuilder`). + +### Layer 2 — declaration forms (the generator) + +The generator emits exactly one interesting thing — a catalog-construction expression for the +current compilation — wrapped in whichever declaration the consumer chose: + +**Declared catalog holder (the primary, multi-assembly form).** The developer declares an empty +`static partial class` and marks it: + +```csharp +// Orders.Domain — references core Paramore.Brighter + the generator (PrivateAssets="all") only +[BrighterRegistrations] +public static partial class OrdersRegistrations; +``` + +The generator adds `public static RegistrationCatalog Catalog { get; }` covering that +compilation's types. The developer controls the name, namespace and visibility, so multiple +assemblies compose without collision. The host composes: + +```csharp +services.AddBrighter(...) + .AddRegistrations(OrdersRegistrations.Catalog, BillingRegistrations.Catalog); +``` + +A plain `partial class` declaration compiles at C# 2-level `partial` support, so this form works +in netstandard2.0 / net48 libraries at their default LangVersion (7.3) — unlike the superseded +extended-partial-method form, which required C# 9. + +**Auto registration (the zero-config, single-project form).** The package ships a `build/` props +file setting the `BrighterAutoRegistration` MSBuild property. When true — and only for a +**direct** `PackageReference`, because the props file is in `build/` not `buildTransitive/` — the +generator synthesises an `internal static class BrighterAssemblyRegistrations` exposing the same +generated `Catalog` plus an `AddFromThisAssembly(this IBrighterBuilder)` sugar whose body is one +`AddRegistrations(Catalog)` call. This form is emitted only when the DI package is referenced +(the sugar's parameter type must resolve) and is suppressed — with info diagnostic `BRGEN007` — +when the compilation also declares a `[BrighterRegistrations]` holder, so the two forms cannot +double-register the same types. Override per-project with +`false`. + +`[ExcludeFromBrighterRegistration]` opts a single type out of discovery under either form. ### Roles and responsibilities The implementation is split so each type has a single, cohesive responsibility, with no Roslyn semantic-model object crossing a pipeline boundary: -| Role (stereotype) | Type | Responsibility | +| Role (stereotype) | Type (package) | Responsibility | |---|---|---| -| Interfacer / information holder | `MarkerSymbols` | *Knowing* the Brighter framework interface symbols (`IHandleRequests<>`, `IHandleRequestsAsync<>`, `IAmAMessageMapper<>`, `IAmAMessageMapperAsync<>`, `IAmAMessageTransform`, `IAmAMessageTransformAsync`) in a given compilation, and whether Brighter is referenced at all. | -| Service provider | `SemanticModelReader` | *Deciding* how a method or class is classified — projecting Roslyn symbols into Roslyn-free, value-equatable records (`MethodCandidate`, `DiscoveredEntry`, `DiagnosticInfo`). | -| Information holder | `Model/*` (`RegistrationModel`, `DiscoveredEntry`, `EquatableArray`, …) | *Knowing* the discovered registrations as pure, equatable data the incremental cache can compare by value. | -| Service provider | `RegistrationWriter` | *Doing* the text generation — turning a `RegistrationModel` into C# source. Holds no Roslyn references, so it is unit-testable without a `Compilation`. | -| Coordinator | `BrighterRegistrationsGenerator` | *Coordinating* the incremental pipeline: wiring the syntax providers, combining streams, and handing models to the writer. | - -### What is generated - -The developer marks a `static partial` method that returns `IBrighterBuilder` and takes a single -`IBrighterBuilder`, with `[BrighterRegistrations]`. The generator implements that partial method, -emitting calls that register every discovered type: - -- **Handlers** via `builder.Handlers(...)` / `builder.AsyncHandlers(...)`. Closed generics use the - strongly-typed `Register()`; open-generic handlers use - `EnsureHandlerIsRegistered(typeof(...))`. -- **Mappers** via `builder.MapperRegistry(...)` (`Add` / `AddAsync`). -- **Transforms** via a new off-interface `BrighterBuilderExtensions.Transforms(...)` extension - (added so transform registration is symmetric with handlers/mappers **without** a binary-breaking - addition to `IBrighterBuilder`). - -`[ExcludeFromBrighterRegistration]` opts a single type out. Generic mappers/transforms cannot be -registered as-is and produce diagnostic `BRGEN005` (warning) rather than being silently dropped. -Malformed registration methods produce `BRGEN001`–`BRGEN004` (errors), giving the compile-time -feedback that scanning never could. A Brighter type declared inside an *open generic* type cannot be -named with concrete type arguments at the registration site, so it is reported as `BRGEN006` -(warning) instead of emitting code that would not compile. When auto-registration is enabled but a -manual `[BrighterRegistrations]` method is present, the suppressed auto path is reported as -`BRGEN007` (info). +| Information holder | `RegistrationCatalog` (`Paramore.Brighter`) | *Knowing* an assembly's handler/mapper/transform registrations as inert, inspectable data. | +| Service provider | `BrighterBuilderExtensions.AddRegistrations` (`…Extensions.DependencyInjection`) | *Doing* the application of catalogs to the builder: non-generic registry adds, the open-generic path, the default-mapper guarantee. | +| Interfacer / information holder | `MarkerSymbols` (generator) | *Knowing* the Brighter framework interface symbols (`IHandleRequests<>`, `IHandleRequestsAsync<>`, `IAmAMessageMapper<>`, `IAmAMessageMapperAsync<>`, `IAmAMessageTransform`, `IAmAMessageTransformAsync`) in a given compilation, and whether Brighter (and the DI package, for the auto sugar) is referenced at all. | +| Service provider | `SemanticModelReader` (generator) | *Deciding* how a declaration is classified — projecting Roslyn symbols into Roslyn-free, value-equatable records (holder candidates, `DiscoveredEntry`, `DiagnosticInfo`). | +| Information holder | `Model/*` (generator) | *Knowing* the discovered registrations as pure, equatable data the incremental cache can compare by value. | +| Service provider | `RegistrationWriter` (generator) | *Doing* the text generation — turning a `RegistrationModel` into the catalog-construction source. Holds no Roslyn references, so it is unit-testable without a `Compilation`. | +| Coordinator | `BrighterRegistrationsGenerator` (generator) | *Coordinating* the incremental pipeline: wiring the syntax providers, combining streams, and handing models to the writer. | + +### Diagnostics + +Malformed holder declarations produce errors in the `BRGEN001`–`BRGEN004` range (not `partial`, +not `static`, generic, or nested in a generic type), giving the compile-time feedback that +scanning never could. Generic mappers/transforms cannot be registered as-is and produce +`BRGEN005` (warning) rather than being silently dropped. A Brighter type declared inside an *open +generic* type cannot be named with concrete type arguments, so it is reported as `BRGEN006` +(warning) instead of emitting code that would not compile. `BRGEN007` (info) reports the +suppressed auto form, as above. Discovery covers both `class` and `record` declarations: handlers must derive from `RequestHandler` (so are always classes), but mappers and transforms implement interfaces only and may legitimately be records. A type is reachable when it (and any containing type) is `public` **or** `internal` — see the accessibility note under Consequences. -### Per-package auto-registration (the referenced-assembly story) - -A source generator only sees the syntax of the **current** compilation; types compiled into a -*referenced* package have no syntax tree in the consumer and are therefore invisible to the -consumer's generator. To let a library contribute its own registrations without the consumer -re-scanning it, the package ships a `build/` props file that sets the -`BrighterAutoRegistration` MSBuild property. When true, the generator additionally synthesises an -`internal static BrighterAssemblyRegistrations.AddFromThisAssembly(this IBrighterBuilder)` -extension covering *that compilation's* types. Because the prop is in `build/` (not -`buildTransitive/`), it only applies to a **direct** `PackageReference`, so a library generates its -own registrations at its own compile time and the consumer calls `AddFromThisAssembly()` — rather -than the consumer trying to load and scan the library at runtime. - -The auto class is **suppressed when the compilation also hand-writes a `[BrighterRegistrations]` -method**, so the default-on auto path and the documented manual path cannot both register the same -types (double registration) or collide on the `AddFromThisAssembly(this IBrighterBuilder)` name -(`CS0121` ambiguous call). The hand-written method takes precedence. - ## Consequences ### Positive @@ -140,37 +224,58 @@ types (double registration) or collide on the `AddFromThisAssembly(this IBrighte compilation, so the "assembly not loaded at scan time" failure mode cannot occur for types in the project (or in a package that adopts this generator). Handlers, mappers **and** transforms are all covered — including the transform case (`JustSayingCompressionTransform`) that #4159 calls out. -- **Failures move from runtime to build time.** Misconfigured registration methods fail compilation +- **Fits layered solutions.** A domain assembly describes its registrations with no dependency + beyond core `Paramore.Brighter`; the host composes any number of catalogs with one + `AddRegistrations` call. No name collisions, no DI/Polly reference in domain projects. +- **Generated code is inert data.** The fragile parts — the open-generic registry cast, the + default-mapper rule, lifetime decisions — live in `AddRegistrations`, versioned and patchable + with Brighter itself, rather than frozen into consumer assemblies at their compile time. +- **Catalogs are inspectable and testable.** Registration coverage can be asserted in a unit test + ("the catalog contains a mapper for `OrderPlaced`") without building a container. +- **Failures move from runtime to build time.** Misdeclared holders fail compilation (`BRGEN001`–`BRGEN004`); unsupported generic mappers/transforms warn (`BRGEN005`). - **Reduces the destructive variant of `MessageMappingException`.** Reliable compile-time registration means fewer messages reach the [ADR 0061](0061-reject_mapping_errors.md) reject path because a mapper/transform was missing. -- **Trimming / AOT friendly and lower startup cost** for projects that rely on generation instead - of scanning. +- **Trimming / AOT friendly and lower startup cost.** Catalogs are `typeof`-based data; the + applier uses the registries' existing non-generic `Add(Type, Type)` surface, so no + `MakeGenericMethod` is introduced. +- **Works at old LangVersions.** The declared-holder form is a plain `partial class`, usable from + netstandard2.0/net48 libraries at default LangVersion 7.3. - **Broad SDK compatibility.** The generator assembly is built against Roslyn 4.8.0 (the first .NET 8 Roslyn, the oldest in-support modern .NET) via a `VersionOverride`, so it loads on that SDK and newer rather than requiring the repo-wide (latest) Roslyn. -- **Behaviourally identical downstream.** Generated registrations flow through the same registries, +- **Behaviourally identical downstream.** Applied registrations flow through the same registries, so `ValidatePipelines` / `DescribePipelines` behave the same. ### Negative - **Referenced-package types are only covered if the package adopts the generator.** A third-party - package that merely ships compiled mappers/transforms (and does not use this generator + props) is - still invisible to the consumer's generator. Those users must register such types explicitly or + package that merely ships compiled mappers/transforms (and does not expose a catalog) is still + invisible to the consumer's generator. Those users must register such types explicitly or continue to use `AutoFromAssemblies`. Source generation does not make scanning obsolete. +- **A new public core type with a compatibility contract.** `RegistrationCatalog` is public API in + core Brighter and — because shipped consumer packages contain generated code bound to it — may + only evolve additively. +- **Weaker compile-time typing than generic registration calls.** The superseded design emitted + `Register()`, which would fail *compilation* on a generator bug that paired + the wrong types; a `typeof`-based catalog defers that class of defect to catalog construction at + startup. Mitigated by argument validation in the `Add*` methods (startup-time, still pre-message) + and by the generator's golden-source test suite. - **Two registration paths to understand.** Scanning and source generation now coexist; teams must know which they are using (and that they are additive). - **Generic mappers/transforms are unsupported** (by design) and require a closed type, a non-generic wrapper, or an explicit exclude. - **Accessibility asymmetry with `AutoFromAssemblies`.** The reflection scanner registers only - `public` (or nested-public) handlers; the generator also registers `internal` types, since - generated code lives in the same assembly and `internal` is genuinely reachable. This is arguably - better, but a team switching mechanisms may see `internal` handlers appear/disappear. -- **Open-generic registration is coupled to `ServiceCollectionSubscriberRegistry`.** Open generics - emit a `(ServiceCollectionSubscriberRegistry)r` cast to reach `EnsureHandlerIsRegistered`. That - holds for the shipped DI extension; if `IBrighterBuilder.Handlers` ever supplied a different - `IAmASubscriberRegistry`, the generated cast would throw `InvalidCastException` at runtime. + `public` (or nested-public) handlers; the generator also registers `internal` types, since the + generated catalog lives in the same assembly and `internal` is genuinely reachable. This is + arguably better, but a team switching mechanisms may see `internal` handlers appear/disappear. + Note that an `internal` handler in a *library* catalog must still be constructable by the host's + container; the applier registers it in the `IServiceCollection` by `Type`, which does not require + the host to reference it in source. +- **Open-generic registration remains coupled to `ServiceCollectionSubscriberRegistry`** — but the + coupling now lives inside `AddRegistrations` in the same package that owns the registry, so the + two can only drift apart by a change within one package. - **Handler inheritance can register a request type twice.** For `class B : A` where `A : RequestHandler`, both `A` and `B` report `IHandleRequests`, so both are registered. This matches the reflection scanner's behaviour rather than introducing a new asymmetry. @@ -179,13 +284,26 @@ types (double registration) or collide on the `AddFromThisAssembly(this IBrighte These are accepted gaps, tracked rather than fixed in this change: +- **The branch currently implements the superseded shape (Alternative 5).** PR + [#4138](https://github.com/BrighterCommand/Brighter/pull/4138) emits imperative + `IBrighterBuilder` method bodies; migrating the writer/pipeline to catalog emission, adding + `RegistrationCatalog` to core and `AddRegistrations` to the DI package is the outstanding work. + The discovery pipeline (`SemanticModelReader.ReadClass`, `DiscoveredEntry`, the incremental + caching design and its tests) carries over unchanged. - **Packaging is not yet wired up.** `IsPackable=false` and there is no companion `*.Package` project, so `dotnet pack` produces nothing and the `build/`-props direct-vs-transitive story is not yet exercised by a real package consumer — only the in-repo `ProjectReference` / `OutputItemType="Analyzer"` path (used by the sample). Shipping should follow the existing `Paramore.Brighter.Analyzer.Package` pattern. -- **Public surface.** The reader/writer/model types are `public` for testability; for a - dev-dependency generator they could be `internal` + `InternalsVisibleTo`. Harmless either way. +- **Public surface of the generator assembly.** The reader/writer/model types are `public` for + testability; for a dev-dependency generator they could be `internal` + `InternalsVisibleTo`. + Harmless either way. +- **Possible future: one-call composition across referenced projects.** Each generated holder + could also emit `[assembly: BrighterRegistrationsProvider(typeof(OrdersRegistrations))]`, letting + a host-side generator scan only the *assembly-level attributes* of direct references + (O(references), no type-walking, incremental-friendly) and synthesise an + `AddFromReferencedAssemblies()`. Deliberately not built now: explicit composition in `Program.cs` + is reviewable, and the catalog design leaves this door open. ### Risks and Mitigations @@ -199,7 +317,7 @@ silently losing third-party package registrations. `AutoFromAssemblies`, producing false positives for apps that register via source generation and have no `AutoFromAssemblies` list. - **Mitigation**: Any such validation must treat source-generated registrations as satisfying the - requirement. Because generated code registers through the same registries, validation that checks + requirement. Because catalogs are applied through the same registries, validation that checks *what is registered* (rather than *which assemblies were listed*) remains correct. **Risk**: A semantic-model object escaping a transform would break incremental caching and regress @@ -220,31 +338,74 @@ Continue to rely on reflection over loaded / listed assemblies. ### Alternative 2: Generator scans referenced assembly *metadata* (symbols), not just syntax Have the generator enumerate `IAssemblySymbol` references and classify their types, so package types -are discovered without the package adopting the generator. +are discovered without the package adopting the generator (the approach taken by +martinothamar/Mediator). **Rejected because**: - Walking referenced metadata on every keystroke is the kind of work incremental generators are designed to avoid; it would hurt IDE performance and caching. - It re-creates reflective ambiguity (which referenced assemblies are "ours"?) at build time. -- The per-package `AddFromThisAssembly` approach gives each library ownership of its own - registrations at its own compile time, which is cleaner and composes. +- Per-assembly catalogs give each library ownership of its own registrations at its own compile + time, which is cleaner and composes. (The assembly-level-attribute hybrid under Deferred + follow-ups recovers the one-call UX without the metadata walk, if ever wanted.) ### Alternative 3: Make registration a runtime source-generated reflection cache Generate a static map but still resolve it reflectively at startup. **Rejected because**: -- Retains a startup reflection step and trimming/AOT hazards for no real gain over emitting direct - registration calls. +- Retains a startup reflection step and trimming/AOT hazards for no real gain over emitting catalog + data applied through the registries. + +### Alternative 4: Add `Transforms(...)` / `AddRegistrations(...)` to `IBrighterBuilder` -### Alternative 4: Add `Transforms(...)` to `IBrighterBuilder` +Put the new registration surface directly on the interface for symmetry with `Handlers` / +`MapperRegistry`. -Put transform registration directly on the interface for symmetry with `Handlers` / `MapperRegistry`. +**Rejected because**: +- It is a binary-breaking change for downstream implementers of `IBrighterBuilder`. Off-interface + extension methods (`BrighterBuilderExtensions.Transforms`, `.AddRegistrations`) achieve the same + ergonomics without breaking the contract. + +### Alternative 5: Emit imperative registration method bodies (the superseded original design) + +The first implementation on this branch generated *behaviour* rather than data: an `internal +static BrighterAssemblyRegistrations.AddFromThisAssembly(this IBrighterBuilder)` extension under +the auto path, and a user-declared `[BrighterRegistrations] static partial IBrighterBuilder +Method(this IBrighterBuilder)` whose body the generator filled with direct +`Handlers`/`MapperRegistry`/`Transforms` calls. + +**Superseded because**: +- **It had no multi-assembly story.** The auto method is `internal`, so a domain assembly that + generated it produced a method its host could not call; composition across several + handler-owning projects was impossible without hand-written wrappers. +- **The manual form forced the DI stack onto domain assemblies.** Its signature is + `IBrighterBuilder → IBrighterBuilder`, so any assembly exposing registrations had to reference + `Paramore.Brighter.Extensions.DependencyInjection` — and transitively + `Microsoft.Extensions.DependencyInjection` and Polly (which appears on the interface). +- **Application logic was frozen into consumer-generated code.** The + `ServiceCollectionSubscriberRegistry` cast for open generics and the always-emit-`MapperRegistry` + default-mapper rule were compiled into every consumer at their build time, unpatchable by a + Brighter release. +- **Extended partial methods require C# 9**, excluding netstandard2.0/net48 libraries at their + default LangVersion. + +The strongly-typed `Register()` calls it emitted did give compile-time +verification of generator output; that loss is recorded under Consequences. + +### Alternative 6: Self-applying module (behaviour object over a registrar abstraction) + +Define `IBrighterRegistrar` in core (generic `Handler()`, `Mapper(...)`, +`Transform(...)`); the generator fills a user-declared type implementing +`void Register(IBrighterRegistrar r)`; the DI package supplies the registrar and a +`builder.AddModule(...)` extension. **Rejected because**: -- It is a binary-breaking change for downstream implementers of `IBrighterBuilder`. An off-interface - extension method (`BrighterBuilderExtensions.Transforms`) achieves the same ergonomics without - breaking the contract. +- The result is opaque: registrations cannot be inspected or asserted on without a spy registrar. +- The registrar abstraction in core must mirror everything the DI layer can do, and evolving an + interface is breaking for implementers (including test doubles). +- Its one advantage — generic instantiations baked at compile time — is unnecessary, since the + registries already expose a non-generic `Add(Type, Type)` surface that is trimming/AOT-safe. ## References @@ -258,3 +419,4 @@ Put transform registration directly on the interface for symmetry with `Handlers - [ADR 0053: Pipeline Validation at Startup](0053-pipeline-validation-at-startup.md) — the `ValidatePipelines` mechanism that must remain correct under source-generated registration. - External references: - [Incremental Generators design](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md) + - [System.Text.Json source generation (`JsonSerializerContext`)](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) — precedent for the user-declared-partial-holder pattern. From 653485fff13015a31547e44abe40e60a6fa829f9 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 12 Jul 2026 19:29:28 +0100 Subject: [PATCH 19/20] docs: settle ADR 0062 open decisions from API exploration - Auto path becomes a synthesised default holder (BrighterRegistrations in the root namespace); AddFromThisAssembly is dropped (Alternative 7) so there is one registration verb and one generated output shape. - Phase 2 designed: [RegistrationGroup] named groups (build-time evaluated, may overlap) and opt-in GenerateBuilderExtensions fluent sugar with the mechanical Add{Name}Registrations rule and its DI-coupling trade. - AddRegistrations applies with set semantics: exact duplicates union as no-ops, found via a prototype where overlapping groups double-registered a handler and failed at dispatch. - Catalog v1 surface gains the Matching/Without combinator pair. Co-Authored-By: Claude Fable 5 --- ...2-source-generated-handler-registration.md | 118 ++++++++++++++++-- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/docs/adr/0062-source-generated-handler-registration.md b/docs/adr/0062-source-generated-handler-registration.md index 581a151c22..8a83d3da35 100644 --- a/docs/adr/0062-source-generated-handler-registration.md +++ b/docs/adr/0062-source-generated-handler-registration.md @@ -2,7 +2,9 @@ Date: 2026-06-04 (amended 2026-07-12: registration artefact reshaped from generated imperative method bodies to a `RegistrationCatalog` data type — see Alternative 5 for the original shape and -why it was superseded) +why it was superseded. Further amended same day: the zero-config `AddFromThisAssembly()` sugar is +dropped in favour of a synthesised default holder (Alternative 7), and registration groups plus +opt-in builder extensions are designed as Phase 2.) ## Status @@ -108,6 +110,10 @@ public sealed class RegistrationCatalog public void AddHandler(Type handlerType, Type? requestType, bool isAsync); // requestType null ⇒ open generic public void AddMapper(Type mapperType, Type requestType, bool isAsync); public void AddTransform(Type transformType); + + // Composition-time subsetting. v1 ships this minimal pair; further combinators are additive. + public RegistrationCatalog Matching(Func predicate); + public RegistrationCatalog Without(params Type[] types); } ``` @@ -122,6 +128,10 @@ public sealed class RegistrationCatalog than the message-receipt-time failure that scanning produces. - Being data, a catalog is inspectable: a team can unit-test "our catalog contains a mapper for `OrderPlaced`" without building a container. +- `Without` gives a host composition-time exclusion of a single handler without touching domain + source — the deployment-side complement to the source-side + `[ExcludeFromBrighterRegistration]`. `Matching` is the general escape hatch that makes named + groups (Phase 2) a convenience rather than a capability. ### Layer 1 — `AddRegistrations` (DI package) @@ -146,6 +156,18 @@ It applies catalogs through the existing public surface: (added in this change so transform registration is symmetric with handlers/mappers **without** a binary-breaking addition to `IBrighterBuilder`). +**Applying registrations is a set union, not an append.** Registering the identical entry — +same `(requestType, handlerType, isAsync)` — twice is a no-op, at two levels: `AddRegistrations` +de-duplicates across the catalogs passed in one call (trivial, since catalogs are data), and the +DI registries treat an exact-duplicate `Add` as idempotent so that *chained* calls union too. +This was found by prototype, not foresight: a handler that legitimately belongs to two +overlapping subsets (e.g. in both a namespace-scoped and a pattern-scoped group under Phase 2) +was registered twice by `.AddBillingRegistrations().AddUrgentRegistrations()` and produced +"More than one handler was found" — at *dispatch* time, exactly the class of runtime surprise +this feature exists to remove. Set semantics match the caller's evident intent ("register the +union of these subsets"). Note this only collapses *exact* duplicates; two *different* handler +types for the same command still fail at dispatch as they do today, which remains correct. + ### Layer 2 — declaration forms (the generator) The generator emits exactly one interesting thing — a catalog-construction expression for the @@ -173,19 +195,68 @@ A plain `partial class` declaration compiles at C# 2-level `partial` support, so in netstandard2.0 / net48 libraries at their default LangVersion (7.3) — unlike the superseded extended-partial-method form, which required C# 9. -**Auto registration (the zero-config, single-project form).** The package ships a `build/` props -file setting the `BrighterAutoRegistration` MSBuild property. When true — and only for a -**direct** `PackageReference`, because the props file is in `build/` not `buildTransitive/` — the -generator synthesises an `internal static class BrighterAssemblyRegistrations` exposing the same -generated `Catalog` plus an `AddFromThisAssembly(this IBrighterBuilder)` sugar whose body is one -`AddRegistrations(Catalog)` call. This form is emitted only when the DI package is referenced -(the sugar's parameter type must resolve) and is suppressed — with info diagnostic `BRGEN007` — -when the compilation also declares a `[BrighterRegistrations]` holder, so the two forms cannot -double-register the same types. Override per-project with +**Auto registration (the zero-config form).** The package ships a `build/` props file setting the +`BrighterAutoRegistration` MSBuild property. When true — and only for a **direct** +`PackageReference`, because the props file is in `build/` not `buildTransitive/` — the generator +behaves *as if the compilation had declared* +`[BrighterRegistrations] internal static partial class BrighterRegistrations;` in the project's +root namespace. The zero-config composition line is therefore the same single verb as every other +form: + +```csharp +services.AddBrighter().AddRegistrations(BrighterRegistrations.Catalog); +``` + +No sugar method is generated (see Alternative 7 for the dropped `AddFromThisAssembly()` and why). +This unifies the generator to **one output shape** — the auto path is a synthesised default +holder, nothing more — and the API to one registration verb. The auto holder is suppressed, with +info diagnostic `BRGEN007`, when the compilation also declares its own `[BrighterRegistrations]` +holder, so the two cannot double-register the same types. Override per-project with `false`. `[ExcludeFromBrighterRegistration]` opts a single type out of discovery under either form. +### Phase 2 (designed now, delivered in a follow-up PR): registration groups and builder sugar + +These are settled design, deliberately excluded from the first PR so the review that introduces +the core public type stays small. Both are purely additive over the catalog data model. + +**Named registration groups.** `[RegistrationGroup]` on the holder declares a named, pre-filtered +sub-catalog scooped by convention: + +```csharp +[BrighterRegistrations] +[RegistrationGroup("Billing", InNamespace = "Orders.Domain.Billing")] +[RegistrationGroup("Urgent", TypeNamePattern = "^Urgent")] +public static partial class OrdersRegistrations; +``` + +The generator evaluates each convention **at build time** against the discovered types and emits +the group as an additional static catalog property (`OrdersRegistrations.Billing`, +`OrdersRegistrations.Urgent`) — plain pre-filtered data; no regex ever runs at runtime. An +invalid pattern or a group matching zero types is a build-time diagnostic. Groups add naming and +build-time checking, not capability: the identical subset is available at composition time via +`Catalog.Matching(...)` (verified with a throwaway two-project prototype), which is what makes +deferring them safe. Groups **may overlap** — a type can match both a namespace convention and a +pattern convention — and composing overlapping groups is safe because Layer 1 applies +registrations with set semantics. + +**Opt-in generated builder extensions.** `[BrighterRegistrations(GenerateBuilderExtensions = +true)]` additionally emits one-line fluent extensions — `AddOrdersRegistrations()` for the whole +holder and `Add{GroupName}Registrations()` per group — each with the body +`=> builder.AddRegistrations()`: + +- Method naming is mechanical (`Add` + user-chosen name + `Registrations`) because holders and + groups already carry human-chosen names. This deliberately avoids the regex method-renaming + machinery NServiceBus needed (`RegistrationMethodNamePatterns`), which exists only because + their generated method names are derived from *type* names. +- The extension's parameter type is `IBrighterBuilder`, so **opting in couples the declaring + assembly to the DI package** (and its `Microsoft.Extensions.DependencyInjection`/Polly graph). + That is the documented trade; the default (off) preserves the core-only posture. Setting the + flag without referencing the DI package is a build-error diagnostic. +- A host that wants fluent sugar *without* coupling its domain assembly can hand-write the same + one-liner around `AddRegistrations` in the composition root. + ### Roles and responsibilities The implementation is split so each type has a single, cohesive responsibility, with no Roslyn @@ -195,7 +266,7 @@ semantic-model object crossing a pipeline boundary: |---|---|---| | Information holder | `RegistrationCatalog` (`Paramore.Brighter`) | *Knowing* an assembly's handler/mapper/transform registrations as inert, inspectable data. | | Service provider | `BrighterBuilderExtensions.AddRegistrations` (`…Extensions.DependencyInjection`) | *Doing* the application of catalogs to the builder: non-generic registry adds, the open-generic path, the default-mapper guarantee. | -| Interfacer / information holder | `MarkerSymbols` (generator) | *Knowing* the Brighter framework interface symbols (`IHandleRequests<>`, `IHandleRequestsAsync<>`, `IAmAMessageMapper<>`, `IAmAMessageMapperAsync<>`, `IAmAMessageTransform`, `IAmAMessageTransformAsync`) in a given compilation, and whether Brighter (and the DI package, for the auto sugar) is referenced at all. | +| Interfacer / information holder | `MarkerSymbols` (generator) | *Knowing* the Brighter framework interface symbols (`IHandleRequests<>`, `IHandleRequestsAsync<>`, `IAmAMessageMapper<>`, `IAmAMessageMapperAsync<>`, `IAmAMessageTransform`, `IAmAMessageTransformAsync`) in a given compilation, and whether Brighter is referenced at all (and, for Phase 2's builder extensions, the DI package). | | Service provider | `SemanticModelReader` (generator) | *Deciding* how a declaration is classified — projecting Roslyn symbols into Roslyn-free, value-equatable records (holder candidates, `DiscoveredEntry`, `DiagnosticInfo`). | | Information holder | `Model/*` (generator) | *Knowing* the discovered registrations as pure, equatable data the incremental cache can compare by value. | | Service provider | `RegistrationWriter` (generator) | *Doing* the text generation — turning a `RegistrationModel` into the catalog-construction source. Holds no Roslyn references, so it is unit-testable without a `Compilation`. | @@ -227,6 +298,10 @@ and may legitimately be records. A type is reachable when it (and any containing - **Fits layered solutions.** A domain assembly describes its registrations with no dependency beyond core `Paramore.Brighter`; the host composes any number of catalogs with one `AddRegistrations` call. No name collisions, no DI/Polly reference in domain projects. +- **One registration verb.** Single-project, multi-project, subsets, and (Phase 2) group sugar + all bottom out in `AddRegistrations(...)`; extracting handlers from a host into a library never + changes the shape of the composition line. There is no second generated API to document, and no + vocabulary overlap with the runtime-scanning `*FromAssemblies` family. - **Generated code is inert data.** The fragile parts — the open-generic registry cast, the default-mapper rule, lifetime decisions — live in `AddRegistrations`, versioned and patchable with Brighter itself, rather than frozen into consumer assemblies at their compile time. @@ -407,6 +482,27 @@ Define `IBrighterRegistrar` in core (generic `Handler()`, `Mapper(. - Its one advantage — generic instantiations baked at compile time — is unnecessary, since the registries already expose a non-generic `Add(Type, Type)` surface that is trimming/AOT-safe. +### Alternative 7: Zero-config sugar method (`AddFromThisAssembly()` / `AddGeneratedRegistrations()`) + +Keep a generated extension method as the zero-config entry point (as the superseded design did +with `internal static BrighterAssemblyRegistrations.AddFromThisAssembly(this IBrighterBuilder)`), +possibly under a better name. + +**Rejected because**: +- The name `AddFromThisAssembly` is doubly wrong at the call site: "this" is deictic (the reader + cannot tell it means "the assembly that generated the method" rather than "the assembly calling + it"), and "From…Assembly" is the vocabulary of the runtime scanning family + (`AutoFromAssemblies`, `HandlersFromAssemblies`) — it makes the compile-time mechanism sound + like the thing it replaces, undermining the "two paths to understand" mitigation. +- A better name (`AddGeneratedRegistrations()`) fixes the wording but not the structure: it still + leaves two registration verbs and a second generated output shape to maintain and document. + Synthesising a default *holder* instead unifies the generator to one output shape and every + composition line to `AddRegistrations(...)`. +- The cost — losing IntelliSense discoverability of the zero-config call on `builder.` — is + accepted: the feature is opted into via a package reference, so the package README is the + discovery surface, and Phase 2's `GenerateBuilderExtensions` offers deliberate, named fluent + sugar where teams want it. + ## References - Related issues: From 5a12d0b1d382cb24e46af0fcd82051b2839c2910 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 12 Jul 2026 19:29:37 +0100 Subject: [PATCH 20/20] docs: add throwaway RegistrationCatalog API prototype Two-project sample (core-only domain + composing host) that fakes the ADR 0062 generated output by hand so the proposed API can be felt at real call sites: flat catalog, named [RegistrationGroup] scoops, runtime combinators, and the Phase-2 fluent extensions. Variant 6 demonstrates the overlapping-groups union that motivated set semantics. Clearly marked throwaway; intended to be dropped from the branch before merge (kept as one commit so a single revert removes it). Co-Authored-By: Claude Fable 5 --- .../Orders.Domain/Billing/Billing.cs | 50 +++++++++ .../Orders.Domain/Fulfilment/Fulfilment.cs | 35 ++++++ .../Notifications/Notifications.cs | 33 ++++++ .../Orders.Domain/Orders.Domain.csproj | 15 +++ .../Orders.Domain/OrdersRegistrations.cs | 21 ++++ .../Orders.Domain/OrdersRegistrations.g.cs | 68 +++++++++++ .../Proposed/RegistrationAttributes.cs | 40 +++++++ .../Proposed/RegistrationCatalog.cs | 73 ++++++++++++ .../Orders.Host/Orders.Host.csproj | 16 +++ ...OrdersRegistrations.BuilderExtensions.g.cs | 41 +++++++ .../Orders.Host/Program.cs | 106 ++++++++++++++++++ .../BrighterBuilderRegistrationExtensions.cs | 67 +++++++++++ .../PROTOTYPE-RegistrationCatalog/README.md | 52 +++++++++ 13 files changed, 617 insertions(+) create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Billing/Billing.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Fulfilment/Fulfilment.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Notifications/Notifications.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Orders.Domain.csproj create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.g.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationAttributes.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationCatalog.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Orders.Host.csproj create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Host/OrdersRegistrations.BuilderExtensions.g.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Program.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Proposed/BrighterBuilderRegistrationExtensions.cs create mode 100644 samples/PROTOTYPE-RegistrationCatalog/README.md diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Billing/Billing.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Billing/Billing.cs new file mode 100644 index 0000000000..b9f0fbf9e0 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Billing/Billing.cs @@ -0,0 +1,50 @@ +// PROTOTYPE — THROWAWAY domain types. +using System; +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; + +namespace Orders.Domain.Billing; + +public sealed class ChargeCard(decimal amount) : Command(Id.Random()) +{ + public decimal Amount { get; } = amount; +} + +public sealed class ChargeCardHandler : RequestHandler +{ + public override ChargeCard Handle(ChargeCard command) + { + Console.WriteLine($" → ChargeCardHandler charged £{command.Amount}"); + return base.Handle(command); + } +} + +public sealed class RefundPayment(decimal amount) : Command(Id.Random()) +{ + public decimal Amount { get; } = amount; +} + +public sealed class RefundPaymentHandler : RequestHandlerAsync +{ + public override Task HandleAsync(RefundPayment command, CancellationToken cancellationToken = default) + { + Console.WriteLine($" → RefundPaymentHandler refunded £{command.Amount}"); + return base.HandleAsync(command, cancellationToken); + } +} + +public sealed class UrgentRefund(decimal amount) : Command(Id.Random()) +{ + public decimal Amount { get; } = amount; +} + +// Deliberately named to be scooped by the "Urgent" TypeNamePattern group, across namespaces. +public sealed class UrgentRefundHandler : RequestHandlerAsync +{ + public override Task HandleAsync(UrgentRefund command, CancellationToken cancellationToken = default) + { + Console.WriteLine($" → UrgentRefundHandler fast-tracked £{command.Amount}"); + return base.HandleAsync(command, cancellationToken); + } +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Fulfilment/Fulfilment.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Fulfilment/Fulfilment.cs new file mode 100644 index 0000000000..19e11c1b69 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Fulfilment/Fulfilment.cs @@ -0,0 +1,35 @@ +// PROTOTYPE — THROWAWAY domain types. +using System; +using Paramore.Brighter; + +namespace Orders.Domain.Fulfilment; + +public sealed class ShipOrder(string sku) : Command(Id.Random()) +{ + public string Sku { get; } = sku; +} + +public sealed class ShipOrderHandler : RequestHandler +{ + public override ShipOrder Handle(ShipOrder command) + { + Console.WriteLine($" → ShipOrderHandler shipped {command.Sku}"); + return base.Handle(command); + } +} + +public sealed class UrgentShipOrder(string sku) : Command(Id.Random()) +{ + public string Sku { get; } = sku; +} + +// Second "Urgent" type, in a different namespace to Billing.UrgentRefundHandler — the +// TypeNamePattern group cuts across the namespace groups. +public sealed class UrgentShipOrderHandler : RequestHandler +{ + public override UrgentShipOrder Handle(UrgentShipOrder command) + { + Console.WriteLine($" → UrgentShipOrderHandler expedited {command.Sku}"); + return base.Handle(command); + } +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Notifications/Notifications.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Notifications/Notifications.cs new file mode 100644 index 0000000000..54aa34f824 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Notifications/Notifications.cs @@ -0,0 +1,33 @@ +// PROTOTYPE — THROWAWAY domain types. +using System; +using System.Threading; +using System.Threading.Tasks; +using Paramore.Brighter; + +namespace Orders.Domain.Notifications; + +public sealed class ReceiptRequested(string email) : Event(Id.Random()) +{ + public string Email { get; } = email; +} + +public sealed class ReceiptRequestedHandler : RequestHandlerAsync +{ + public override Task HandleAsync(ReceiptRequested @event, CancellationToken cancellationToken = default) + { + Console.WriteLine($" → ReceiptRequestedHandler emailed {@event.Email}"); + return base.HandleAsync(@event, cancellationToken); + } +} + +// Never invoked by the prototype (no external bus); exists so the catalog has a mapper entry. +public sealed class ReceiptRequestedMapper : IAmAMessageMapper +{ + public IRequestContext? Context { get; set; } + + public Message MapToMessage(ReceiptRequested request, Publication publication) => + throw new NotImplementedException("prototype"); + + public ReceiptRequested MapToRequest(Message message) => + throw new NotImplementedException("prototype"); +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Orders.Domain.csproj b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Orders.Domain.csproj new file mode 100644 index 0000000000..69bfa88ec6 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Orders.Domain.csproj @@ -0,0 +1,15 @@ + + + net9.0 + enable + + + + + + + diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.cs new file mode 100644 index 0000000000..01f23b27b5 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.cs @@ -0,0 +1,21 @@ +// ───────────────────────────────────────────────────────────────────────────────────── +// THIS FILE IS WHAT THE USER WRITES. Everything else about registration is generated. +// +// The holder declaration is the whole per-assembly ceremony: name, namespace and visibility +// are user-controlled, so any number of assemblies compose in the host without collision. +// [RegistrationGroup] declares named convention scoops (evaluated at build time) — drop the +// attributes and you still get the flat `Catalog`. +// ───────────────────────────────────────────────────────────────────────────────────── +using Paramore.Brighter; + +namespace Orders.Domain; + +// GenerateBuilderExtensions is the Phase-2 opt-in for fluent sugar (see the host's +// OrdersRegistrations.BuilderExtensions.g.cs). In the real design those extensions are generated +// INTO THIS PROJECT, which would then have to reference the DI package — the prototype parks the +// file in the host instead so Orders.Domain keeps demonstrating the core-only posture. +[BrighterRegistrations(GenerateBuilderExtensions = true)] +[RegistrationGroup("Billing", InNamespace = "Orders.Domain.Billing")] +[RegistrationGroup("Fulfilment", InNamespace = "Orders.Domain.Fulfilment")] +[RegistrationGroup("Urgent", TypeNamePattern = "^Urgent")] +public static partial class OrdersRegistrations; diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.g.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.g.cs new file mode 100644 index 0000000000..34230a59da --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/OrdersRegistrations.g.cs @@ -0,0 +1,68 @@ +// +// ───────────────────────────────────────────────────────────────────────────────────── +// PROTOTYPE — hand-written stand-in for what BrighterRegistrationsGenerator WOULD emit +// for the declaration in OrdersRegistrations.cs. +// +// Note what is NOT here: no builder calls, no open-generic casts, no default-mapper logic. +// The generator emits inert data; AddRegistrations (library code) does the thinking. +// [RegistrationGroup] conventions were evaluated at generation time against the discovered +// type names, so each group below is just another pre-filtered block of static data — +// the regex never runs at runtime. +// ───────────────────────────────────────────────────────────────────────────────────── +#nullable enable + +namespace Orders.Domain +{ + public static partial class OrdersRegistrations + { + /// Every Brighter type discovered in this assembly. + public static global::Paramore.Brighter.RegistrationCatalog Catalog { get; } = BuildCatalog(); + + /// [RegistrationGroup("Billing", InNamespace = "Orders.Domain.Billing")] + public static global::Paramore.Brighter.RegistrationCatalog Billing { get; } = BuildBilling(); + + /// [RegistrationGroup("Fulfilment", InNamespace = "Orders.Domain.Fulfilment")] + public static global::Paramore.Brighter.RegistrationCatalog Fulfilment { get; } = BuildFulfilment(); + + /// [RegistrationGroup("Urgent", TypeNamePattern = "^Urgent")] + public static global::Paramore.Brighter.RegistrationCatalog Urgent { get; } = BuildUrgent(); + + private static global::Paramore.Brighter.RegistrationCatalog BuildCatalog() + { + var catalog = new global::Paramore.Brighter.RegistrationCatalog(); + catalog.AddHandler(typeof(global::Orders.Domain.Billing.ChargeCardHandler), typeof(global::Orders.Domain.Billing.ChargeCard), isAsync: false); + catalog.AddHandler(typeof(global::Orders.Domain.Billing.RefundPaymentHandler), typeof(global::Orders.Domain.Billing.RefundPayment), isAsync: true); + catalog.AddHandler(typeof(global::Orders.Domain.Billing.UrgentRefundHandler), typeof(global::Orders.Domain.Billing.UrgentRefund), isAsync: true); + catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.ShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.ShipOrder), isAsync: false); + catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.UrgentShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.UrgentShipOrder), isAsync: false); + catalog.AddHandler(typeof(global::Orders.Domain.Notifications.ReceiptRequestedHandler), typeof(global::Orders.Domain.Notifications.ReceiptRequested), isAsync: true); + catalog.AddMapper(typeof(global::Orders.Domain.Notifications.ReceiptRequestedMapper), typeof(global::Orders.Domain.Notifications.ReceiptRequested), isAsync: false); + return catalog; + } + + private static global::Paramore.Brighter.RegistrationCatalog BuildBilling() + { + var catalog = new global::Paramore.Brighter.RegistrationCatalog(); + catalog.AddHandler(typeof(global::Orders.Domain.Billing.ChargeCardHandler), typeof(global::Orders.Domain.Billing.ChargeCard), isAsync: false); + catalog.AddHandler(typeof(global::Orders.Domain.Billing.RefundPaymentHandler), typeof(global::Orders.Domain.Billing.RefundPayment), isAsync: true); + catalog.AddHandler(typeof(global::Orders.Domain.Billing.UrgentRefundHandler), typeof(global::Orders.Domain.Billing.UrgentRefund), isAsync: true); + return catalog; + } + + private static global::Paramore.Brighter.RegistrationCatalog BuildFulfilment() + { + var catalog = new global::Paramore.Brighter.RegistrationCatalog(); + catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.ShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.ShipOrder), isAsync: false); + catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.UrgentShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.UrgentShipOrder), isAsync: false); + return catalog; + } + + private static global::Paramore.Brighter.RegistrationCatalog BuildUrgent() + { + var catalog = new global::Paramore.Brighter.RegistrationCatalog(); + catalog.AddHandler(typeof(global::Orders.Domain.Billing.UrgentRefundHandler), typeof(global::Orders.Domain.Billing.UrgentRefund), isAsync: true); + catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.UrgentShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.UrgentShipOrder), isAsync: false); + return catalog; + } + } +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationAttributes.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationAttributes.cs new file mode 100644 index 0000000000..ffbc9400dc --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationAttributes.cs @@ -0,0 +1,40 @@ +// ───────────────────────────────────────────────────────────────────────────────────── +// PROTOTYPE — THROWAWAY. In the real design these are emitted internal-per-assembly by the +// generator's post-init output (like the PR's existing BrighterRegistrationsAttribute). +// ───────────────────────────────────────────────────────────────────────────────────── +using System; + +namespace Paramore.Brighter; + +/// Marks a static partial class the generator fills with a RegistrationCatalog. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class BrighterRegistrationsAttribute : Attribute +{ + /// + /// Phase 2: also emit one-line fluent extensions — Add{HolderName}() for the whole catalog and + /// Add{GroupName}Registrations() per [RegistrationGroup] — each delegating to AddRegistrations. + /// DELIBERATE TRADE: the extensions take IBrighterBuilder, so opting in couples the declaring + /// assembly to the DI package. Setting this without referencing it is a build-error diagnostic. + /// + public bool GenerateBuilderExtensions { get; set; } +} + +/// +/// Declares a named sub-catalog scooped by convention — the Brighter analogue of NServiceBus's +/// RegistrationMethodNamePatterns. The generator evaluates the convention AT BUILD TIME against +/// the discovered types and emits the group as a pre-filtered static catalog property named +/// . An invalid regex or a group that matches nothing is a build-time +/// diagnostic, not a runtime surprise. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class RegistrationGroupAttribute(string name) : Attribute +{ + /// Generated property name; must be a valid C# identifier. + public string Name { get; } = name; + + /// Scoop every discovered type in this namespace (and below). + public string? InNamespace { get; set; } + + /// Scoop every discovered type whose simple name matches this regex. + public string? TypeNamePattern { get; set; } +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationCatalog.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationCatalog.cs new file mode 100644 index 0000000000..6b039e60e7 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Domain/Proposed/RegistrationCatalog.cs @@ -0,0 +1,73 @@ +// ───────────────────────────────────────────────────────────────────────────────────── +// PROTOTYPE — THROWAWAY. Stand-in for a type that would ship in core Paramore.Brighter +// (see ADR 0062). Declared here, in Brighter's namespace, so the call sites read exactly +// as they would for real. +// ───────────────────────────────────────────────────────────────────────────────────── +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Paramore.Brighter; + +/// +/// Inert description of an assembly's handler/mapper/transform registrations. Constructed by +/// generated code via the additive Add* methods; applied by the DI package's AddRegistrations. +/// +public sealed class RegistrationCatalog +{ + private readonly List _handlers = []; + private readonly List _mappers = []; + private readonly List _transforms = []; + + public IReadOnlyList Handlers => _handlers; + public IReadOnlyList Mappers => _mappers; + public IReadOnlyList Transforms => _transforms; + + // Additive construction surface (never a growing ctor): generated code binds to these. + public void AddHandler(Type handlerType, Type? requestType, bool isAsync) => + _handlers.Add(new HandlerRegistration(handlerType, requestType, isAsync)); + + public void AddMapper(Type mapperType, Type requestType, bool isAsync) => + _mappers.Add(new MapperRegistration(mapperType, requestType, isAsync)); + + public void AddTransform(Type transformType) => _transforms.Add(transformType); + + // ── Runtime combinators ───────────────────────────────────────────────────────── + // The "flat data" answer to NServiceBus's generated named groups: because a catalog is + // data, scooping subsets is ordinary library code — no generator feature required. + + /// Keep entries whose implementation type matches the predicate. + public RegistrationCatalog Matching(Func predicate) + { + var subset = new RegistrationCatalog(); + subset._handlers.AddRange(_handlers.Where(h => predicate(h.HandlerType))); + subset._mappers.AddRange(_mappers.Where(m => predicate(m.MapperType))); + subset._transforms.AddRange(_transforms.Where(predicate)); + return subset; + } + + /// Keep entries whose implementation type lives in the given namespace (or below). + public RegistrationCatalog InNamespace(string ns) => + Matching(t => t.Namespace == ns || (t.Namespace?.StartsWith(ns + ".", StringComparison.Ordinal) ?? false)); + + /// Remove specific implementation types — composition-time exclusion without touching source. + public RegistrationCatalog Without(params Type[] types) => + Matching(t => !types.Contains(t)); + + public IEnumerable Describe() + { + foreach (var h in _handlers) + yield return $"handler {h.HandlerType.Name,-24} ← {h.RequestType?.Name ?? "(open generic)",-18} {(h.IsAsync ? "async" : "sync")}"; + foreach (var m in _mappers) + yield return $"mapper {m.MapperType.Name,-24} ← {m.RequestType.Name,-18} {(m.IsAsync ? "async" : "sync")}"; + foreach (var t in _transforms) + yield return $"transform {t.Name}"; + } + + public override string ToString() => + $"{_handlers.Count} handlers, {_mappers.Count} mappers, {_transforms.Count} transforms"; +} + +public readonly record struct HandlerRegistration(Type HandlerType, Type? RequestType, bool IsAsync); + +public readonly record struct MapperRegistration(Type MapperType, Type RequestType, bool IsAsync); diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Orders.Host.csproj b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Orders.Host.csproj new file mode 100644 index 0000000000..55b7978714 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Orders.Host.csproj @@ -0,0 +1,16 @@ + + + net9.0 + Exe + enable + + + + + + + + + + + diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/OrdersRegistrations.BuilderExtensions.g.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/OrdersRegistrations.BuilderExtensions.g.cs new file mode 100644 index 0000000000..5c5a3549f1 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/OrdersRegistrations.BuilderExtensions.g.cs @@ -0,0 +1,41 @@ +// +// ───────────────────────────────────────────────────────────────────────────────────── +// PROTOTYPE — hand-written stand-in for the Phase-2 output of +// [BrighterRegistrations(GenerateBuilderExtensions = true)]. +// +// In the real design this file is generated INTO Orders.Domain, which then must reference +// Paramore.Brighter.Extensions.DependencyInjection (the documented coupling trade). It lives +// in the host here only so the prototype's Orders.Domain stays core-only. +// +// Method naming is mechanical — Add + user-chosen holder/group name (+ "Registrations") — +// because holders and groups already carry human-chosen names. No regex renaming machinery +// (cf. NServiceBus RegistrationMethodNamePatterns) is needed. Every body is a one-liner +// over AddRegistrations: sugar, never logic. +// ───────────────────────────────────────────────────────────────────────────────────── +#nullable enable + +namespace Orders.Domain +{ + public static class OrdersRegistrationsBuilderExtensions + { + /// Registers everything in . + public static global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddOrdersRegistrations( + this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + => global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderRegistrationExtensions.AddRegistrations(builder, global::Orders.Domain.OrdersRegistrations.Catalog); + + /// Registers the [RegistrationGroup("Billing")] group. + public static global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddBillingRegistrations( + this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + => global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderRegistrationExtensions.AddRegistrations(builder, global::Orders.Domain.OrdersRegistrations.Billing); + + /// Registers the [RegistrationGroup("Fulfilment")] group. + public static global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddFulfilmentRegistrations( + this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + => global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderRegistrationExtensions.AddRegistrations(builder, global::Orders.Domain.OrdersRegistrations.Fulfilment); + + /// Registers the [RegistrationGroup("Urgent")] group. + public static global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder AddUrgentRegistrations( + this global::Paramore.Brighter.Extensions.DependencyInjection.IBrighterBuilder builder) + => global::Paramore.Brighter.Extensions.DependencyInjection.BrighterBuilderRegistrationExtensions.AddRegistrations(builder, global::Orders.Domain.OrdersRegistrations.Urgent); + } +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Program.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Program.cs new file mode 100644 index 0000000000..f24d585ad3 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Program.cs @@ -0,0 +1,106 @@ +// ───────────────────────────────────────────────────────────────────────────────────── +// PROTOTYPE driver — builds a fresh container per composition variant, prints the +// catalog(s) (inspectability is part of the design), then fires every message and shows +// which handlers were — and deliberately were not — registered. +// ───────────────────────────────────────────────────────────────────────────────────── +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Orders.Domain; +using Orders.Domain.Billing; +using Orders.Domain.Fulfilment; +using Orders.Domain.Notifications; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; + +await Variant( + "1) Flat: everything the assembly declares", + ".AddRegistrations(OrdersRegistrations.Catalog)", + [OrdersRegistrations.Catalog]); + +await Variant( + "2) Named groups from [RegistrationGroup] namespace conventions", + ".AddRegistrations(OrdersRegistrations.Billing, OrdersRegistrations.Fulfilment)", + [OrdersRegistrations.Billing, OrdersRegistrations.Fulfilment]); + +await Variant( + "3) Named group from a TypeNamePattern convention (cuts across namespaces)", + ".AddRegistrations(OrdersRegistrations.Urgent)", + [OrdersRegistrations.Urgent]); + +await Variant( + "4) Flat + runtime combinators — same scooping, no generator feature involved", + """.AddRegistrations(OrdersRegistrations.Catalog.Matching(t => t.Name.StartsWith("Urgent")))""", + [OrdersRegistrations.Catalog.Matching(t => t.Name.StartsWith("Urgent"))]); + +await Variant( + "5) Combinators compose: Billing minus one handler, plus Notifications", + """.AddRegistrations(OrdersRegistrations.Billing.Without(typeof(UrgentRefundHandler)), OrdersRegistrations.Catalog.InNamespace("Orders.Domain.Notifications"))""", + [OrdersRegistrations.Billing.Without(typeof(UrgentRefundHandler)), + OrdersRegistrations.Catalog.InNamespace("Orders.Domain.Notifications")]); + +await Variant( + "6) Phase-2 sugar: generated fluent extensions (one-liners over the same catalogs)", + ".AddBillingRegistrations().AddUrgentRegistrations()", + [OrdersRegistrations.Billing, OrdersRegistrations.Urgent], + b => b.AddBillingRegistrations().AddUrgentRegistrations()); + +return; + +static async Task Variant(string title, string code, RegistrationCatalog[] catalogs, Action? compose = null) +{ + compose ??= b => b.AddRegistrations(catalogs); + Console.WriteLine(); + Console.WriteLine($"══ {title} ".PadRight(100, '═')); + Console.WriteLine($" {code}"); + Console.WriteLine($" catalog contents ({string.Join(" + ", catalogs.AsEnumerable())}):"); + foreach (var catalog in catalogs) + foreach (var line in catalog.Describe()) + Console.WriteLine($" {line}"); + + var services = new ServiceCollection(); + compose(services.AddBrighter()); + await using var provider = services.BuildServiceProvider(); + var processor = provider.GetRequiredService(); + + Console.WriteLine(" sending one of everything:"); + Try("Send(ChargeCard)", () => processor.Send(new ChargeCard(42.00m))); + await TryAsync("SendAsync(RefundPayment)", () => processor.SendAsync(new RefundPayment(9.99m))); + await TryAsync("SendAsync(UrgentRefund)", () => processor.SendAsync(new UrgentRefund(100.00m))); + Try("Send(ShipOrder)", () => processor.Send(new ShipOrder("SKU-1"))); + Try("Send(UrgentShipOrder)", () => processor.Send(new UrgentShipOrder("SKU-2"))); + await TryAsync("PublishAsync(ReceiptRequested)", () => processor.PublishAsync(new ReceiptRequested("a@b.com"))); +} + +static void Try(string label, Action send) +{ + try + { + Console.WriteLine($" {label}"); + send(); + } + catch (Exception e) + { + Console.WriteLine($" ✗ {e.GetType().Name}: {FirstLine(e.Message)}"); + } +} + +static async Task TryAsync(string label, Func send) +{ + try + { + Console.WriteLine($" {label}"); + await send(); + } + catch (Exception e) + { + Console.WriteLine($" ✗ {e.GetType().Name}: {FirstLine(e.Message)}"); + } +} + +static string FirstLine(string s) +{ + var i = s.IndexOf('\n'); + return i < 0 ? s : s[..i].TrimEnd(); +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Proposed/BrighterBuilderRegistrationExtensions.cs b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Proposed/BrighterBuilderRegistrationExtensions.cs new file mode 100644 index 0000000000..b79ea42aa4 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/Orders.Host/Proposed/BrighterBuilderRegistrationExtensions.cs @@ -0,0 +1,67 @@ +// ───────────────────────────────────────────────────────────────────────────────────── +// PROTOTYPE — THROWAWAY. Stand-in for the AddRegistrations extension that would ship in +// Paramore.Brighter.Extensions.DependencyInjection (see ADR 0062). All the "thinking" the +// old design baked into generated code lives here instead: registry casts, the default- +// mapper guarantee, and (in the real thing) the open-generic path. +// +// The casts below are legitimate in the real implementation because this code lives in the +// same package that owns ServiceCollectionSubscriberRegistry — it is not a consumer-visible +// hazard the way a cast in generated code is. +// ───────────────────────────────────────────────────────────────────────────────────── +using System.Linq; +using Paramore.Brighter; + +namespace Paramore.Brighter.Extensions.DependencyInjection; + +public static class BrighterBuilderRegistrationExtensions +{ + public static IBrighterBuilder AddRegistrations(this IBrighterBuilder builder, params RegistrationCatalog[] catalogs) + { + // SET SEMANTICS: applying registrations is a union, not an append. Identical entries are + // de-duplicated across the catalogs in this call (Distinct over value records), and entries + // already present in the registry are skipped so *chained* AddRegistrations calls union too + // (overlapping groups — e.g. a handler in both Billing and Urgent — register once). + // The prototype checks via the registry's inspector; the real implementation would make the + // registry's Add itself idempotent for exact duplicates. + builder.Handlers(r => + { + var registry = (ServiceCollectionSubscriberRegistry)r; + foreach (var handler in catalogs.SelectMany(c => c.Handlers).Where(h => !h.IsAsync).Distinct()) + if (!registry.GetHandlerTypes(handler.RequestType!).Contains(handler.HandlerType)) + registry.Add(handler.RequestType!, handler.HandlerType); + }); + + builder.AsyncHandlers(r => + { + var registry = (ServiceCollectionSubscriberRegistry)r; + foreach (var handler in catalogs.SelectMany(c => c.Handlers).Where(h => h.IsAsync).Distinct()) + if (!registry.GetHandlerTypes(handler.RequestType!).Contains(handler.HandlerType)) + registry.Add(handler.RequestType!, handler.HandlerType); + }); + + // Always called, even with zero mappers: preserves the default-message-mapper + // guarantee (EnsureDefaultMessageMapperIsRegistered) that AutoFromAssemblies gives. + builder.MapperRegistry(r => + { + foreach (var mapper in catalogs.SelectMany(c => c.Mappers).Distinct()) + { + if (mapper.IsAsync) + r.AddAsync(mapper.RequestType, mapper.MapperType); + else + r.Add(mapper.RequestType, mapper.MapperType); + } + }); + + var transforms = catalogs.SelectMany(c => c.Transforms).ToList(); + if (transforms.Count > 0) + { + builder.Transforms(r => + { + foreach (var transform in transforms) + r.Add(transform); + }); + } + + return builder; + } +} diff --git a/samples/PROTOTYPE-RegistrationCatalog/README.md b/samples/PROTOTYPE-RegistrationCatalog/README.md new file mode 100644 index 0000000000..3695023fb3 --- /dev/null +++ b/samples/PROTOTYPE-RegistrationCatalog/README.md @@ -0,0 +1,52 @@ +# PROTOTYPE — RegistrationCatalog API feel (THROWAWAY) + +**Do not ship anything in this directory.** It exists to answer one design question for +[ADR 0062](../../docs/adr/0062-source-generated-handler-registration.md) / PR #4138: + +> What does the proposed `RegistrationCatalog` API feel like at the call site — and do +> NServiceBus-style *named convention groups* (`[RegistrationGroup]`, cf. their +> `RegistrationMethodNamePatterns`) earn their keep over a flatter catalog with runtime +> combinators? + +## Run it + +```bash +dotnet run --project samples/PROTOTYPE-RegistrationCatalog/Orders.Host +``` + +The driver builds a fresh container per composition variant, prints the catalog contents +(demonstrating inspectability), then fires every message and reports which handlers were — +and deliberately were not — registered. + +## Layout + +- **`Orders.Domain`** — the handler-owning project. References **core `Paramore.Brighter` + only** (that's the design's key claim: no DI package, no Polly, in domain assemblies). + - `Billing/`, `Fulfilment/`, `Notifications/` — 6 handlers (sync + async mix) and a mapper. + - `OrdersRegistrations.cs` — what the **user writes**: the declared holder + group conventions. + - `OrdersRegistrations.g.cs` — hand-written stand-in for what the **generator would emit**. + - `Proposed/` — stand-ins for types that would ship in core `Paramore.Brighter`. +- **`Orders.Host`** — the composition root. References the domain project + the DI package. + - `Proposed/BrighterBuilderRegistrationExtensions.cs` — stand-in for the `AddRegistrations` + extension that would ship in `Paramore.Brighter.Extensions.DependencyInjection`. + - `Program.cs` — the variants under evaluation, side by side. + +## The three shapes being compared + +```csharp +// A. Named groups, declared once by convention, generated as static data: +.AddRegistrations(OrdersRegistrations.Billing, OrdersRegistrations.Urgent) + +// B. Flat catalog + runtime combinators, no generator features involved: +.AddRegistrations(OrdersRegistrations.Catalog.InNamespace("Orders.Domain.Billing")) + +// C. Phase-2 opt-in fluent sugar ([BrighterRegistrations(GenerateBuilderExtensions = true)]): +.AddBillingRegistrations().AddUrgentRegistrations() +``` + +Note the group patterns (`[RegistrationGroup("Urgent", TypeNamePattern = "^Urgent")]`) are +evaluated **at build time** by the generator against discovered type names — a group is just +another pre-filtered static catalog, so shapes A and C are sugar over shape B's data model. +Shape C's extensions take `IBrighterBuilder`, so opting in couples the declaring assembly to +the DI package — that's the trade; see `Orders.Host/OrdersRegistrations.BuilderExtensions.g.cs` +for why the prototype parks that file in the host.