diff --git a/README.md b/README.md index e0e6dd6..ddc105e 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,47 @@ public static partial class TypeDiscovery } ``` +### Use a template expression for each matched type + +When `Handler` is not flexible enough — for example when you need to pass extra arguments, call static methods with multiple parameters, or perform `typeof` manipulations — use `HandlerTemplate`. +The placeholder `T` is replaced with the fully-qualified name of each matched type at code-generation time. + +Instantiate types with constructor arguments: +```csharp +public static partial class Factory +{ + [ScanForTypes(AssignableTo = typeof(IPlugin), HandlerTemplate = "new T(options)")] + public static partial IPlugin[] CreatePlugins(PluginOptions options); +} +``` + +Call a static factory method that takes multiple parameters: +```csharp +public static partial class PipelineBuilder +{ + [ScanForTypes(AssignableTo = typeof(IPipelineStep), HandlerTemplate = "T.Create(context, logger)")] + public static partial IPipelineStep[] BuildSteps(PipelineContext context, ILogger logger); +} +``` + +Build descriptor objects using `typeof(T)` and additional context: +```csharp +public static partial class HandlerRegistry +{ + [ScanForTypes(AssignableTo = typeof(ICommandHandler), HandlerTemplate = "new HandlerDescriptor(typeof(T), category)")] + public static partial HandlerDescriptor[] GetDescriptors(string category); +} +``` + +`HandlerTemplate` works equally well with void methods, where each expanded expression becomes a statement: +```csharp +public static partial class PluginLoader +{ + [ScanForTypes(AssignableTo = typeof(IPlugin), HandlerTemplate = "registry.Add(new T(options))")] + public static partial void RegisterPlugins(PluginRegistry registry, PluginOptions options); +} +``` + ## Parameters @@ -195,6 +236,7 @@ public static partial class TypeDiscovery | Property | Description | | --- | --- | | **Handler** | Sets this property to invoke a custom method for each type found. This property should point to one of the following:
- Name of a generic method in the current type.
- Static method name in found types.
**Note:** Types are automatically filtered by the generic constraints defined on the method's type parameters (e.g., `class`, `struct`, `new()`, interface constraints). | +| **HandlerTemplate** | Sets an expression template to evaluate for each type found. Use `T` as a placeholder for the fully-qualified name of each matched type. For collection-returning methods the template is the element expression; for `void` methods it becomes a statement (a `;` is appended automatically if absent). Incompatible with `Handler`. | | **FromAssemblyOf** | Sets the assembly containing the given type as the source of types to scan. If not specified, the assembly containing the method with this attribute will be used. | | **AssemblyNameFilter** | Sets this value to filter scanned assemblies by assembly name. This option is incompatible with `FromAssemblyOf`. You can use '*' wildcards. You can also use ',' to separate multiple filters. | | **AssignableTo** | Sets the type that the scanned types must be assignable to. | diff --git a/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs b/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs index 85c7d57..8772f6b 100644 --- a/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs +++ b/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs @@ -1674,4 +1674,179 @@ public class MyService : IService { } Assert.Equal(results.Diagnostics.Single().Descriptor, DiagnosticDescriptors.MissingCustomHandlerOnGenerateServiceHandler); } + + [Fact] + public void ScanForTypesAttribute_HandlerTemplate_ReturnsCollection() + { + var source = """ + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(AssignableTo = typeof(IService), HandlerTemplate = "new T(argument)")] + public static partial IService[] GetServiceInstances(string argument); + } + """; + + var services = + """ + namespace GeneratorTests; + + public interface IService { } + public class MyService1 : IService { public MyService1(string x) { } } + public class MyService2 : IService { public MyService2(string x) { } } + """; + + var compilation = CreateCompilation(source, services); + + var results = CSharpGeneratorDriver + .Create(_generator) + .RunGenerators(compilation) + .GetRunResult(); + + var expected = """ + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + public static partial global::GeneratorTests.IService[] GetServiceInstances( string argument) + { + return [ + new global::GeneratorTests.MyService1(argument), + new global::GeneratorTests.MyService2(argument) + ]; + } + } + """; + Assert.Equal(expected, results.GeneratedTrees[2].ToString()); + } + + [Fact] + public void ScanForTypesAttribute_HandlerTemplate_VoidMethod() + { + var source = """ + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(AssignableTo = typeof(IService), HandlerTemplate = "registry.Add(new T(argument))")] + public static partial void RegisterServices(ServiceRegistry registry, string argument); + } + """; + + var services = + """ + namespace GeneratorTests; + + public interface IService { } + public class MyService1 : IService { public MyService1(string x) { } } + public class MyService2 : IService { public MyService2(string x) { } } + public class ServiceRegistry { public void Add(IService s) { } } + """; + + var compilation = CreateCompilation(source, services); + + var results = CSharpGeneratorDriver + .Create(_generator) + .RunGenerators(compilation) + .GetRunResult(); + + var expected = """ + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + public static partial void RegisterServices( global::GeneratorTests.ServiceRegistry registry, string argument) + { + registry.Add(new global::GeneratorTests.MyService1(argument)); + registry.Add(new global::GeneratorTests.MyService2(argument)); + } + } + """; + Assert.Equal(expected, results.GeneratedTrees[2].ToString()); + } + + [Fact] + public void ScanForTypesAttribute_HandlerTemplate_StatementWithSemicolon() + { + var source = """ + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(AssignableTo = typeof(IService), HandlerTemplate = "registry.Add(new T(argument));")] + public static partial void RegisterServices(ServiceRegistry registry, string argument); + } + """; + + var services = + """ + namespace GeneratorTests; + + public interface IService { } + public class MyService1 : IService { public MyService1(string x) { } } + public class ServiceRegistry { public void Add(IService s) { } } + """; + + var compilation = CreateCompilation(source, services); + + var results = CSharpGeneratorDriver + .Create(_generator) + .RunGenerators(compilation) + .GetRunResult(); + + var expected = """ + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + public static partial void RegisterServices( global::GeneratorTests.ServiceRegistry registry, string argument) + { + registry.Add(new global::GeneratorTests.MyService1(argument)); + } + } + """; + Assert.Equal(expected, results.GeneratedTrees[2].ToString()); + } + + [Fact] + public void ScanForTypesAttribute_BothHandlerAndHandlerTemplate_ReportsDiagnostic() + { + var source = """ + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(AssignableTo = typeof(IService), Handler = nameof(GetServiceInfo), HandlerTemplate = "new T()")] + public static partial IService[] GetServiceInstances(); + + private static IService GetServiceInfo() where T : IService, new() => new T(); + } + """; + + var services = + """ + namespace GeneratorTests; + + public interface IService { } + public class MyService : IService { } + """; + + var compilation = CreateCompilation(source, services); + + var results = CSharpGeneratorDriver + .Create(_generator) + .RunGenerators(compilation) + .GetRunResult(); + + Assert.Equal(results.Diagnostics.Single().Descriptor, DiagnosticDescriptors.CantUseBothHandlerAndHandlerTemplate); + } } diff --git a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.FindServicesToRegister.cs b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.FindServicesToRegister.cs index fbd50f6..c866dee 100644 --- a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.FindServicesToRegister.cs +++ b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.FindServicesToRegister.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using ServiceScan.SourceGenerator.Model; using static ServiceScan.SourceGenerator.DiagnosticDescriptors; @@ -13,6 +14,8 @@ public partial class DependencyInjectionGenerator "System.IAsyncDisposable" ]; + private static readonly Regex TypePlaceholderRegex = new(@"\bT\b", RegexOptions.Compiled); + private static DiagnosticModel FindServicesToRegister((DiagnosticModel, Compilation) context) { var (diagnosticModel, compilation) = context; @@ -40,6 +43,8 @@ private static DiagnosticModel FindServicesToRegister AddCollectionItems(implementationType, matchedTypes, attribute, method, collectionItems); else if (attribute.CustomHandler != null) AddCustomHandlerItems(implementationType, matchedTypes, attribute, customHandlers); + else if (attribute.HandlerTemplate != null) + AddTemplateStatementItem(implementationType, attribute, customHandlers); else { var implementationTypeName = implementationType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -106,10 +111,14 @@ private static void AddCollectionItems( { var implementationTypeName = implementationType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (attribute.CustomHandler == null) + if (attribute.CustomHandler == null && attribute.HandlerTemplate == null) { collectionItems.Add($"typeof({implementationTypeName})"); } + else if (attribute.HandlerTemplate != null) + { + collectionItems.Add(ExpandTemplate(attribute.HandlerTemplate, implementationTypeName)); + } else { var arguments = string.Join(", ", method.Parameters.Select(p => p.Name)); @@ -172,6 +181,28 @@ .. matchedType.TypeArguments.Select(a => a.ToDisplayString(SymbolDisplayFormat.F } } + private static void AddTemplateStatementItem( + INamedTypeSymbol implementationType, + AttributeModel attribute, + List customHandlers) + { + var implementationTypeName = implementationType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var statement = ExpandTemplate(attribute.HandlerTemplate!, implementationTypeName); + if (!statement.EndsWith(";") && !statement.TrimEnd().EndsWith(";")) + statement += ";"; + + customHandlers.Add(new CustomHandlerModel( + Model.CustomHandlerType.Template, + statement, + implementationTypeName, + [])); + } + + private static string ExpandTemplate(string template, string typeName) + { + return TypePlaceholderRegex.Replace(template, typeName); + } + private static IEnumerable GetSuitableInterfaces(ITypeSymbol type) { return type.AllInterfaces.Where(x => !ExcludedInterfaces.Contains(x.ToDisplayString())); diff --git a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs index 99738a3..6d13549 100644 --- a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs +++ b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs @@ -129,13 +129,17 @@ public partial class DependencyInjectionGenerator foreach (var attribute in attributeData) { - if (attribute.CustomHandler == null) + if (attribute.CustomHandler != null && attribute.HandlerTemplate != null) { - // Without a Handler, the method must return Type[] or IEnumerable + return Diagnostic.Create(CantUseBothHandlerAndHandlerTemplate, attribute.Location); + } + else if (attribute.CustomHandler == null && attribute.HandlerTemplate == null) + { + // Without a Handler or HandlerTemplate, the method must return Type[] or IEnumerable if (!isTypeCollection) return Diagnostic.Create(MissingCustomHandlerOnGenerateServiceHandler, attribute.Location); } - else + else if (attribute.CustomHandler != null) { var customHandlerMethod = method.ContainingType.GetMethod(attribute.CustomHandler, context.SemanticModel, position); diff --git a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs index 36bce65..f5be124 100644 --- a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs +++ b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs @@ -158,7 +158,11 @@ private static string GenerateCustomHandlingSource(MethodModel method, Equatable { var invocations = string.Join("\n", customHandlers.Select(h => { - if (h.CustomHandlerType == CustomHandlerType.Method) + if (h.CustomHandlerType == CustomHandlerType.Template) + { + return $" {h.HandlerMethodName}"; + } + else if (h.CustomHandlerType == CustomHandlerType.Method) { var genericArguments = string.Join(", ", h.TypeArguments); var arguments = string.Join(", ", method.Parameters.Select(p => p.Name)); diff --git a/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs b/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs index 69b00c8..dfe0a5f 100644 --- a/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs +++ b/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs @@ -94,4 +94,11 @@ public static class DiagnosticDescriptors "Usage", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor CantUseBothHandlerAndHandlerTemplate = new("DI0016", + "Cannot use both Handler and HandlerTemplate", + "It is not allowed to use both Handler and HandlerTemplate in the same attribute", + "Usage", + DiagnosticSeverity.Error, + true); } diff --git a/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs b/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs index 26f9b8b..da25145 100644 --- a/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs +++ b/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs @@ -125,9 +125,22 @@ internal class ScanForTypesAttribute : Attribute /// This property should point to one of the following: /// - Name of a generic method in the current type. /// - Static method name in found types. + /// This property is incompatible with . /// public string? Handler { get; set; } + /// + /// Sets an expression template to evaluate for each type found. + /// Use T as a placeholder for the full name of each found type. + /// For void methods (or methods returning their first argument), the template is used as a statement; + /// a semicolon is appended automatically if not present. + /// For methods returning a collection, the template is used as the expression for each collection element. + /// This property is incompatible with . + /// + /// new T(argument) + /// registry.Add(new T(argument)) + public string? HandlerTemplate { get; set; } + /// /// Sets the assembly containing the given type as the source of types to scan. /// If not specified, the assembly containing the method with this attribute will be used. diff --git a/ServiceScan.SourceGenerator/Model/AttributeModel.cs b/ServiceScan.SourceGenerator/Model/AttributeModel.cs index cf38995..6d51878 100644 --- a/ServiceScan.SourceGenerator/Model/AttributeModel.cs +++ b/ServiceScan.SourceGenerator/Model/AttributeModel.cs @@ -5,7 +5,7 @@ namespace ServiceScan.SourceGenerator.Model; enum KeySelectorType { Method, GenericMethod, TypeMember }; -enum CustomHandlerType { Method, TypeMethod }; +enum CustomHandlerType { Method, TypeMethod, Template }; record AttributeModel( string? AssignableToTypeName, @@ -28,7 +28,8 @@ record AttributeModel( bool AsImplementedInterfaces, bool AsSelf, Location Location, - bool HasErrors) + bool HasErrors, + string? HandlerTemplate) { public bool HasSearchCriteria => TypeNameFilter != null || AssignableToTypeName != null || AttributeFilterTypeName != null; @@ -49,6 +50,7 @@ public static AttributeModel Create(AttributeData attribute, IMethodSymbol metho var keySelector = attribute.NamedArguments.FirstOrDefault(a => a.Key == "KeySelector").Value.Value as string; var customHandler = (attribute.NamedArguments.FirstOrDefault(a => a.Key == "Handler").Value.Value ?? attribute.NamedArguments.FirstOrDefault(a => a.Key == "CustomHandler").Value.Value) as string; + var handlerTemplate = attribute.NamedArguments.FirstOrDefault(a => a.Key == "HandlerTemplate").Value.Value as string; var assignableToTypeParametersCount = assignableTo?.TypeParameters.Length ?? 0; @@ -86,6 +88,9 @@ public static AttributeModel Create(AttributeData attribute, IMethodSymbol metho if (string.IsNullOrWhiteSpace(assemblyNameFilter)) assemblyNameFilter = null; + if (string.IsNullOrWhiteSpace(handlerTemplate)) + handlerTemplate = null; + var attributeFilterTypeName = attributeFilterType?.ToFullMetadataName(); var excludeByAttributeTypeName = excludeByAttributeType?.ToFullMetadataName(); var assemblyOfTypeName = assemblyType?.ToFullMetadataName(); @@ -134,6 +139,7 @@ public static AttributeModel Create(AttributeData attribute, IMethodSymbol metho asImplementedInterfaces, asSelf, location, - hasError); + hasError, + handlerTemplate); } } \ No newline at end of file