diff --git a/README.md b/README.md index 4a54718..f6d4fdf 100644 --- a/README.md +++ b/README.md @@ -93,14 +93,14 @@ public class HelloWorldEndpoint : IEndpoint public static partial class ServiceCollectionExtensions { - [GenerateServiceRegistrations(AssignableTo = typeof(IEndpoint), CustomHandler = nameof(IEndpoint.MapEndpoint))] + [ScanForTypes(AssignableTo = typeof(IEndpoint), Handler = nameof(IEndpoint.MapEndpoint))] public static partial IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder endpoints); } ``` ### Register Options types -Another example of `CustomHandler` is to register Options types. We can define custom `OptionAttribute`, which allows to specify configuration section key. -And then read that value in our `CustomHandler`: +Another example of `Handler` is to register Options types. We can define custom `OptionAttribute`, which allows to specify configuration section key. +And then read that value in our `Handler`: ```csharp [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class OptionAttribute(string? section = null) : Attribute @@ -116,7 +116,7 @@ public record SectionOption { } public static partial class ServiceCollectionExtensions { - [GenerateServiceRegistrations(AttributeFilter = typeof(OptionAttribute), CustomHandler = nameof(AddOption))] + [ScanForTypes(AttributeFilter = typeof(OptionAttribute), Handler = nameof(AddOption))] public static partial IServiceCollection AddOptions(this IServiceCollection services, IConfiguration configuration); private static void AddOption(IServiceCollection services, IConfiguration configuration) where T : class @@ -133,7 +133,7 @@ public static partial class ServiceCollectionExtensions ```csharp public static partial class ModelBuilderExtensions { - [GenerateServiceRegistrations(AssignableTo = typeof(IEntityTypeConfiguration<>), CustomHandler = nameof(ApplyConfiguration))] + [ScanForTypes(AssignableTo = typeof(IEntityTypeConfiguration<>), Handler = nameof(ApplyConfiguration))] public static partial ModelBuilder ApplyEntityConfigurations(this ModelBuilder modelBuilder); private static void ApplyConfiguration(ModelBuilder modelBuilder) @@ -164,4 +164,17 @@ public static partial class ModelBuilderExtensions | **ExcludeByTypeName** | Sets this value to exclude types from being registered by their full name. You can use '*' wildcards. You can also use ',' to separate multiple filters. | | **ExcludeByAttribute** | Excludes matching types by the specified attribute type being present. | | **KeySelector** | Sets this property to add types as keyed services. This property should point to one of the following:
- The name of a static method in the current type with a string return type. The method should be either generic or have a single parameter of type `Type`.
- A constant field or static property in the implementation type. | -| **CustomHandler** | Sets this property to invoke a custom method for each type found instead of regular registration logic. 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 `Lifetime`, `AsImplementedInterfaces`, `AsSelf`, and `KeySelector` properties.
**Note:** When using a generic `CustomHandler` method, types are automatically filtered by the generic constraints defined on the method's type parameters (e.g., `class`, `struct`, `new()`, interface constraints). | +| **CustomHandler** | *(Obsolete — use `ScanForTypes` instead.)* Sets this property to invoke a custom method for each type found instead of regular registration logic. | + +`ScanForTypes` attribute is used to invoke a custom method for each matched type. It has the same filtering properties as `GenerateServiceRegistrations`, but without the registration-specific ones (`Lifetime`, `AsImplementedInterfaces`, `AsSelf`, `KeySelector`): +| 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). | +| **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. | +| **ExcludeAssignableTo** | Sets the type that the scanned types must *not* be assignable to. | +| **TypeNameFilter** | Sets this value to filter the types by their full name. You can use '*' wildcards. You can also use ',' to separate multiple filters. | +| **AttributeFilter** | Filters types by the specified attribute type being present. | +| **ExcludeByTypeName** | Sets this value to exclude types by their full name. You can use '*' wildcards. You can also use ',' to separate multiple filters. | +| **ExcludeByAttribute** | Excludes matching types by the specified attribute type being present. | diff --git a/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs b/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs index 4b65b65..4a027dd 100644 --- a/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs +++ b/ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs @@ -1099,4 +1099,248 @@ private static Compilation CreateCompilation(params string[] source) ], new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); } + + [Fact] + public void ScanForTypesAttribute_WithNoParameters() + { + var source = $$""" + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(AssignableTo = typeof(IService), Handler = nameof(HandleType))] + public static partial void ProcessServices(); + + private static void HandleType() => System.Console.WriteLine(typeof(T).Name); + } + """; + + var services = + """ + namespace GeneratorTests; + + public interface IService { } + public class MyService1 : IService { } + public class MyService2 : IService { } + """; + + 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 ProcessServices() + { + HandleType(); + HandleType(); + } + } + """; + Assert.Equal(expected, results.GeneratedTrees[2].ToString()); + } + + [Fact] + public void ScanForTypesAttribute_WithParameters() + { + var source = $$""" + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(TypeNameFilter = "*Service", Handler = nameof(HandleType))] + public static partial void ProcessServices(string value); + + private static void HandleType(string value) => System.Console.WriteLine(value + typeof(T).Name); + } + """; + + var services = + """ + namespace GeneratorTests; + + public class MyFirstService {} + public class MySecondService {} + public class ServiceWithNonMatchingName {} + """; + + 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 ProcessServices( string value) + { + HandleType(value); + HandleType(value); + } + } + """; + Assert.Equal(expected, results.GeneratedTrees[2].ToString()); + } + + [Fact] + public void ScanForTypesAttribute_MultipleAttributes() + { + var source = $$""" + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(AssignableTo = typeof(IFirstService), Handler = nameof(HandleFirstType))] + [ScanForTypes(AssignableTo = typeof(ISecondService), Handler = nameof(HandleSecondType))] + public static partial void ProcessServices(); + + private static void HandleFirstType() => System.Console.WriteLine("First:" + typeof(T).Name); + private static void HandleSecondType() => System.Console.WriteLine("Second:" + typeof(T).Name); + } + """; + + var services = + """ + namespace GeneratorTests; + + public interface IFirstService { } + public interface ISecondService { } + public class MyService1 : IFirstService { } + public class MyService2 : ISecondService { } + """; + + 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 ProcessServices() + { + HandleFirstType(); + HandleSecondType(); + } + } + """; + Assert.Equal(expected, results.GeneratedTrees[2].ToString()); + } + + [Fact] + public void ScanForTypesAttribute_MissingHandler_ReportsDiagnostic() + { + var source = $$""" + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(AssignableTo = typeof(IService))] + public static partial void ProcessServices(); + } + """; + + 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.MissingCustomHandlerOnGenerateServiceHandler); + } + + [Fact] + public void ScanForTypesAttribute_MissingSearchCriteria_ReportsDiagnostic() + { + var source = $$""" + using ServiceScan.SourceGenerator; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [ScanForTypes(Handler = nameof(HandleType))] + public static partial void ProcessServices(); + + private static void HandleType() { } + } + """; + + var compilation = CreateCompilation(source); + + var results = CSharpGeneratorDriver + .Create(_generator) + .RunGenerators(compilation) + .GetRunResult(); + + Assert.Equal(results.Diagnostics.Single().Descriptor, DiagnosticDescriptors.MissingSearchCriteria); + } + + [Fact] + public void MixingGenerateServiceRegistrationsAndScanForTypes_ReportsDiagnostic() + { + var source = $$""" + using ServiceScan.SourceGenerator; + using Microsoft.Extensions.DependencyInjection; + + namespace GeneratorTests; + + public static partial class ServicesExtensions + { + [GenerateServiceRegistrations(AssignableTo = typeof(IService))] + [ScanForTypes(AssignableTo = typeof(IService), Handler = nameof(HandleType))] + public static partial IServiceCollection ProcessServices(this IServiceCollection services); + + private static void HandleType() { } + } + """; + + 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.Contains(results.Diagnostics, d => d.Descriptor == DiagnosticDescriptors.CantMixServiceRegistrationsAndServiceHandler); + } } diff --git a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs index 27e774a..c699244 100644 --- a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs +++ b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs @@ -17,6 +17,13 @@ public partial class DependencyInjectionGenerator if (!method.IsPartialDefinition) return Diagnostic.Create(NotPartialDefinition, method.Locations[0]); + // Check if ScanForTypesAttribute is also on this method - that's not allowed + var hasServiceHandlerAttribute = method.GetAttributes() + .Any(a => a.AttributeClass?.ToDisplayString() == GenerateAttributeInfo.HandlerMetadataName); + + if (hasServiceHandlerAttribute) + return Diagnostic.Create(CantMixServiceRegistrationsAndServiceHandler, method.Locations[0]); + var position = context.TargetNode.SpanStart; var attributeData = context.Attributes.Select(a => AttributeModel.Create(a, method, context.SemanticModel)).ToArray(); var hasCustomHandlers = attributeData.Any(a => a.CustomHandler != null); @@ -94,4 +101,63 @@ public partial class DependencyInjectionGenerator var model = MethodModel.Create(method, context.TargetNode); return new MethodWithAttributesModel(model, [.. attributeData]); } + + private static DiagnosticModel? ParseHandlerMethodModel(GeneratorAttributeSyntaxContext context) + { + if (context.TargetSymbol is not IMethodSymbol method) + return null; + + if (!method.IsPartialDefinition) + return Diagnostic.Create(NotPartialDefinition, method.Locations[0]); + + // Skip if this method also has GenerateServiceRegistrationsAttribute - that provider reports the mixing error + var hasServiceRegistrationsAttribute = method.GetAttributes() + .Any(a => a.AttributeClass?.ToDisplayString() == GenerateAttributeInfo.MetadataName); + + if (hasServiceRegistrationsAttribute) + return null; + + var position = context.TargetNode.SpanStart; + var attributeData = context.Attributes.Select(a => AttributeModel.Create(a, method, context.SemanticModel)).ToArray(); + + foreach (var attribute in attributeData) + { + if (attribute.CustomHandler == null) + return Diagnostic.Create(MissingCustomHandlerOnGenerateServiceHandler, attribute.Location); + + if (!attribute.HasSearchCriteria) + return Diagnostic.Create(MissingSearchCriteria, attribute.Location); + + if (attribute.AssemblyOfTypeName != null && attribute.AssemblyNameFilter != null) + return Diagnostic.Create(CantUseBothFromAssemblyOfAndAssemblyNameFilter, attribute.Location); + + var customHandlerMethod = method.ContainingType.GetMethod(attribute.CustomHandler, context.SemanticModel, position); + + if (customHandlerMethod != null) + { + if (!customHandlerMethod.IsGenericMethod) + return Diagnostic.Create(CustomHandlerMethodHasIncorrectSignature, attribute.Location); + + var typesMatch = Enumerable.SequenceEqual( + method.Parameters.Select(p => p.Type), + customHandlerMethod.Parameters.Select(p => p.Type), + SymbolEqualityComparer.Default); + + if (!typesMatch) + return Diagnostic.Create(CustomHandlerMethodHasIncorrectSignature, attribute.Location); + } + + if (attribute.HasErrors) + return null; + } + + if (!method.ReturnsVoid && + (method.Parameters.Length == 0 || !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, method.ReturnType))) + { + return Diagnostic.Create(WrongReturnTypeForCustomHandler, method.Locations[0]); + } + + var model = MethodModel.Create(method, context.TargetNode); + return new MethodWithAttributesModel(model, [.. attributeData]); + } } diff --git a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs index 0ef50fc..b4800c2 100644 --- a/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs +++ b/ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs @@ -32,23 +32,40 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select(static (context, ct) => FindServicesToRegister(context)); context.RegisterSourceOutput(methodImplementationsProvider, - static (context, src) => - { - if (src.Diagnostic != null) - context.ReportDiagnostic(src.Diagnostic); + static (context, src) => GenerateSource(context, src)); + + var handlerMethodProvider = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeInfo.HandlerMetadataName, + predicate: static (syntaxNode, ct) => syntaxNode is MethodDeclarationSyntax, + transform: static (context, ct) => ParseHandlerMethodModel(context)) + .Where(method => method != null); + + var combinedHandlerProvider = handlerMethodProvider.Combine(context.CompilationProvider) + .WithComparer(CombinedProviderComparer.Instance); + + var handlerImplementationsProvider = combinedHandlerProvider + .Select(static (context, ct) => FindServicesToRegister(context)); + + context.RegisterSourceOutput(handlerImplementationsProvider, + static (context, src) => GenerateSource(context, src)); + } + + private static void GenerateSource(SourceProductionContext context, DiagnosticModel src) + { + if (src.Diagnostic != null) + context.ReportDiagnostic(src.Diagnostic); - if (src.Model == null) - return; + if (src.Model == null) + return; - var (method, registrations, customHandling) = src.Model; - string source = registrations.Count > 0 - ? GenerateRegistrationsSource(method, registrations) - : GenerateCustomHandlingSource(method, customHandling); + var (method, registrations, customHandling) = src.Model; + string source = registrations.Count > 0 + ? GenerateRegistrationsSource(method, registrations) + : GenerateCustomHandlingSource(method, customHandling); - source = source.ReplaceLineEndings(); + source = source.ReplaceLineEndings(); - context.AddSource($"{method.TypeName}_{method.MethodName}.Generated.cs", SourceText.From(source, Encoding.UTF8)); - }); + context.AddSource($"{method.TypeName}_{method.MethodName}.Generated.cs", SourceText.From(source, Encoding.UTF8)); } private static string GenerateRegistrationsSource(MethodModel method, EquatableArray registrations) diff --git a/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs b/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs index 36bdcef..54c9d78 100644 --- a/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs +++ b/ServiceScan.SourceGenerator/DiagnosticDescriptors.cs @@ -73,4 +73,18 @@ public static class DiagnosticDescriptors "Usage", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor MissingCustomHandlerOnGenerateServiceHandler = new("DI0013", + "Missing Handler", + "ScanForTypesAttribute must have Handler specified", + "Usage", + DiagnosticSeverity.Error, + true); + + public static readonly DiagnosticDescriptor CantMixServiceRegistrationsAndServiceHandler = new("DI0014", + "Cannot mix GenerateServiceRegistrationsAttribute and ScanForTypesAttribute", + "It is not allowed to use both GenerateServiceRegistrationsAttribute and ScanForTypesAttribute on the same method", + "Usage", + DiagnosticSeverity.Error, + true); } diff --git a/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs b/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs index d2dfc5e..26f9b8b 100644 --- a/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs +++ b/ServiceScan.SourceGenerator/GenerateAttributeInfo.cs @@ -3,6 +3,7 @@ internal static class GenerateAttributeInfo { public const string MetadataName = "ServiceScan.SourceGenerator.GenerateServiceRegistrationsAttribute"; + public const string HandlerMetadataName = "ServiceScan.SourceGenerator.ScanForTypesAttribute"; public const string Source = """ #nullable enable @@ -110,8 +111,78 @@ internal class GenerateServiceRegistrationsAttribute : Attribute /// This property is incompatible with , , , /// and properties. /// + /// This property is obsolete. Use instead. + [Obsolete("Use ScanForTypesAttribute instead.", error: false)] public string? CustomHandler { get; set; } } + + [Embedded] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + internal class ScanForTypesAttribute : Attribute + { + /// + /// 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. + /// + public string? Handler { 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. + /// + public Type? FromAssemblyOf { get; set; } + + /// + /// Sets this value to filter scanned assemblies by assembly name. + /// It allows applying an attribute to multiple assemblies. + /// For example, this allows scanning all assemblies from your solution. + /// This option is incompatible with . + /// You can use '*' wildcards. You can also use ',' to separate multiple filters. + /// + /// Be careful to include a limited number of assemblies, as it can affect build and editor performance. + /// My.Product.* + public string? AssemblyNameFilter { get; set; } + + /// + /// Sets the type that the scanned types must be assignable to. + /// + public Type? AssignableTo { get; set; } + + /// + /// Sets the type that the scanned types must *not* be assignable to. + /// + public Type? ExcludeAssignableTo { get; set; } + + /// + /// Sets this value to filter the types by their full name. + /// You can use '*' wildcards. + /// You can also use ',' to separate multiple filters. + /// + /// Namespace.With.Services.* + /// *Service,*Factory + public string? TypeNameFilter { get; set; } + + /// + /// Filters types by the specified attribute type being present. + /// + public Type? AttributeFilter { get; set; } + + /// + /// Sets this value to exclude types by their full name. + /// You can use '*' wildcards. + /// You can also use ',' to separate multiple filters. + /// + /// Namespace.With.Services.* + /// *Service,*Factory + public string? ExcludeByTypeName { get; set; } + + /// + /// Excludes matching types by the specified attribute type being present. + /// + public Type? ExcludeByAttribute { get; set; } + } """; } diff --git a/ServiceScan.SourceGenerator/Model/AttributeModel.cs b/ServiceScan.SourceGenerator/Model/AttributeModel.cs index 60d8b83..cf38995 100644 --- a/ServiceScan.SourceGenerator/Model/AttributeModel.cs +++ b/ServiceScan.SourceGenerator/Model/AttributeModel.cs @@ -47,7 +47,8 @@ public static AttributeModel Create(AttributeData attribute, IMethodSymbol metho var excludeByTypeName = attribute.NamedArguments.FirstOrDefault(a => a.Key == "ExcludeByTypeName").Value.Value as string; var excludeAssignableTo = attribute.NamedArguments.FirstOrDefault(a => a.Key == "ExcludeAssignableTo").Value.Value as INamedTypeSymbol; var keySelector = attribute.NamedArguments.FirstOrDefault(a => a.Key == "KeySelector").Value.Value as string; - var customHandler = attribute.NamedArguments.FirstOrDefault(a => a.Key == "CustomHandler").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 assignableToTypeParametersCount = assignableTo?.TypeParameters.Length ?? 0; diff --git a/version.json b/version.json index c629a25..824037e 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.4", + "version": "3.0", "publicReleaseRefSpec": [ "^refs/heads/main", "^refs/heads/v\\d+(?:\\.\\d+)?$"