Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,8 @@ public class HelloWorldEndpoint : IEndpoint

public static partial class ServiceCollectionExtensions
{
[GenerateServiceRegistrations(AssignableTo = typeof(IEndpoint), CustomHandler = nameof(MapEndpoint))]
[GenerateServiceRegistrations(AssignableTo = typeof(IEndpoint), CustomHandler = nameof(IEndpoint.MapEndpoint))]
public static partial IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder endpoints);

private static void MapEndpoint<T>(IEndpointRouteBuilder endpoints) where T : IEndpoint
{
T.MapEndpoint(endpoints);
}
}
```

Expand Down Expand Up @@ -157,16 +152,16 @@ public static partial class ModelBuilderExtensions
`GenerateServiceRegistrations` attribute has the following properties:
| Property | Description |
| --- | --- |
| **FromAssemblyOf** | Set the assembly containing the given type as the source of types to register. If not specified, the assembly containing the method with this attribute will be used. |
| **AssemblyNameFilter** | Set this value to filter scanned assemblies by assembly name. It allows to apply an attribute to multiple assemblies. For example, this allows to scan all assemblies from your solution. You can use '\*' wildcards. You can also use ',' to separate multiple filters. *Be careful to include limited amount of assemblies, as it can affect build and editor performance.* |
| **AssignableTo** | Set the type that the registered types must be assignable to. Types will be registered with this type as the service type, unless `AsImplementedInterfaces` or `AsSelf` is set. |
| **Lifetime** | Set the lifetime of the registered services. `ServiceLifetime.Transient` is used if not specified. |
| **AsImplementedInterfaces** | If true, the registered types will be registered as implemented interfaces instead of their actual type. |
| **AsSelf** | If true, types will be registered with their actual type. It can be combined with `AsImplementedInterfaces`. In that case, implemented interfaces will be "forwarded" to an actual implementation type. |
| **TypeNameFilter** | Set this value to filter the types to register by their full name. You can use '*' wildcards. You can also use ',' to separate multiple filters. |
| **AttributeFilter** | Filter types by the specified attribute type present. |
| **ExcludeByTypeName** | Set 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** | Exclude matching types by the specified attribute type present. |
| **ExcludeAssignableTo** | Set the type that the registered types must not be assignable to. |
| **KeySelector** | Set this property to add types as keyed services. This property should point to one of the following: <br>- Name of the 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`. <br>- Const field or static property in the implementation type. |
| **CustomHandler** | Set this property to a static generic method name in the current class. This method will be invoked for each type found by the filter instead of the regular registration logic. This property is incompatible with `Lifetime`, `AsImplementedInterfaces`, `AsSelf`, and `KeySelector` properties. |
| **FromAssemblyOf** | Sets the assembly containing the given type as the source of types to register. 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. It allows applying an attribute to multiple assemblies. For example, this allows scanning all assemblies from your solution. This option is incompatible with `FromAssemblyOf`. 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.* |
| **AssignableTo** | Sets the type that the registered types must be assignable to. Types will be registered with this type as the service type, unless `AsImplementedInterfaces` or `AsSelf` is set. |
| **ExcludeAssignableTo** | Sets the type that the registered types must *not* be assignable to. |
| **Lifetime** | Sets the lifetime of the registered services. `ServiceLifetime.Transient` is used if not specified. |
| **AsImplementedInterfaces** | If set to true, types will be registered as their implemented interfaces instead of their actual type. |
| **AsSelf** | If set to true, types will be registered with their actual type. It can be combined with `AsImplementedInterfaces`. In this case, implemented interfaces will be "forwarded" to the "self" implementation. |
| **TypeNameFilter** | Sets this value to filter the types to register 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 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: <br>- 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`. <br>- 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: <br>- Name of a generic method in the current type. <br>- Static method name in found types. <br>This property is incompatible with `Lifetime`, `AsImplementedInterfaces`, `AsSelf`, and `KeySelector` properties. |
107 changes: 107 additions & 0 deletions ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,113 @@ public partial void ProcessServices()
Assert.Equal(expected, results.GeneratedTrees[1].ToString());
}

[Fact]
public void UseStaticMethodFromMatchedClassAsCustomHandler_WithoutParameters()
{
var source = $$"""
using ServiceScan.SourceGenerator;

namespace GeneratorTests;

public partial class ServicesExtensions
{
[GenerateServiceRegistrations(AssignableTo = typeof(IService), CustomHandler = "Handler"))]
public partial void ProcessServices();
}
""";

var services =
"""
namespace GeneratorTests;

public interface IService { }

public class MyService1 : IService
{
public static void Handler() { }
}

public class MyService2 : IService
{
public static void Handler() { }
}
""";

var compilation = CreateCompilation(source, services);

var results = CSharpGeneratorDriver
.Create(_generator)
.RunGenerators(compilation)
.GetRunResult();

var expected = $$"""
namespace GeneratorTests;

public partial class ServicesExtensions
{
public partial void ProcessServices()
{
global::GeneratorTests.MyService1.Handler();
global::GeneratorTests.MyService2.Handler();
}
}
""";
Assert.Equal(expected, results.GeneratedTrees[1].ToString());
}

[Fact]
public void UseStaticMethodFromMatchedStaticClassAsCustomHandler_WithParameters()
{
var source = $$"""
using ServiceScan.SourceGenerator;
using Microsoft.Extensions.DependencyInjection;

namespace GeneratorTests;

public partial class ServicesExtensions
{
[GenerateServiceRegistrations(TypeNameFilter = "*StaticService", CustomHandler = "Handler"))]
public partial void ProcessServices(IServiceCollection services);
}
""";

var services =
"""
namespace GeneratorTests;

public static class FirstStaticService
{
public static void Handler(IServiceCollection services) { }
}

public static class SecondStaticService
{
public static void Handler(IServiceCollection services) { }
}
""";

var compilation = CreateCompilation(source, services);

var results = CSharpGeneratorDriver
.Create(_generator)
.RunGenerators(compilation)
.GetRunResult();

var expected = $$"""
namespace GeneratorTests;

public partial class ServicesExtensions
{
public partial void ProcessServices( global::Microsoft.Extensions.DependencyInjection.IServiceCollection services)
{
global::GeneratorTests.FirstStaticService.Handler(services);
global::GeneratorTests.SecondStaticService.Handler(services);
}
}
""";
Assert.Equal(expected, results.GeneratedTrees[1].ToString());
}

private static Compilation CreateCompilation(params string[] source)
{
var path = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ public partial class DependencyInjectionGenerator

foreach (var type in assemblies.SelectMany(GetTypesFromAssembly))
{
if (type.IsAbstract || type.IsStatic || !type.CanBeReferencedByName || type.TypeKind != TypeKind.Class)
if (type.IsAbstract || !type.CanBeReferencedByName || type.TypeKind != TypeKind.Class)
continue;

// Static types are allowed for custom handlers (with type method)
if (type.IsStatic && attribute.CustomHandlerType != CustomHandlerType.TypeMethod)
continue;

if (attributeFilterType != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,34 @@ private static DiagnosticModel<MethodImplementationModel> FindServicesToRegister

if (attribute.CustomHandler != null)
{
var implementationTypeName = implementationType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);

// If CustomHandler method has multiple type parameters, which are resolvable from the first one - we try to provide them.
// e.g. ApplyConfiguration<T, TEntity>(ModelBuilder modelBuilder) where T : IEntityTypeConfiguration<TEntity>
if (attribute.CustomHandlerTypeParametersCount > 1 && matchedTypes != null)
if (attribute.CustomHandlerMethodTypeParametersCount > 1 && matchedTypes != null)
{
foreach (var matchedType in matchedTypes)
{
EquatableArray<string> typeArguments =
[
implementationType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
implementationTypeName,
.. matchedType.TypeArguments.Select(a => a.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
];

customHandlers.Add(new CustomHandlerModel(attribute.CustomHandler, typeArguments));
customHandlers.Add(new CustomHandlerModel(
attribute.CustomHandlerType.Value,
attribute.CustomHandler,
implementationTypeName,
typeArguments));
}
}
else
{
customHandlers.Add(new CustomHandlerModel(attribute.CustomHandler, [implementationType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)]));
customHandlers.Add(new CustomHandlerModel(
attribute.CustomHandlerType.Value,
attribute.CustomHandler,
implementationTypeName,
[implementationTypeName]));
}
}
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,27 +56,27 @@ public partial class DependencyInjectionGenerator
var customHandlerMethod = method.ContainingType.GetMembers().OfType<IMethodSymbol>()
.FirstOrDefault(m => m.Name == attribute.CustomHandler);

if (customHandlerMethod is null)
return Diagnostic.Create(CustomHandlerMethodNotFound, attribute.Location);

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 CustomHandler has more than 1 type parameters, we try to resolve them from
// matched assignableTo type arguments.
// e.g. ApplyConfiguration<T, TEntity>(ModelBuilder modelBuilder) where T : IEntityTypeConfiguration<TEntity>
if (customHandlerMethod.TypeParameters.Length > 1
&& customHandlerMethod.TypeParameters.Length != attribute.AssignableToTypeParametersCount + 1)
if (customHandlerMethod != null)
{
Copy link

Copilot AI Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic only applies to Method type custom handlers but doesn't validate TypeMethod handlers. Consider adding validation for static methods in target types to ensure they exist and have correct signatures.

Copilot uses AI. Check for mistakes.
return Diagnostic.Create(CustomHandlerMethodHasIncorrectSignature, attribute.Location);
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 CustomHandler has more than 1 type parameters, we try to resolve them from
// matched assignableTo type arguments.
// e.g. ApplyConfiguration<T, TEntity>(ModelBuilder modelBuilder) where T : IEntityTypeConfiguration<TEntity>
if (customHandlerMethod.TypeParameters.Length > 1
&& customHandlerMethod.TypeParameters.Length != attribute.AssignableToTypeParametersCount + 1)
{
return Diagnostic.Create(CustomHandlerMethodHasIncorrectSignature, attribute.Location);
}
}
}

Expand Down
14 changes: 11 additions & 3 deletions ServiceScan.SourceGenerator/DependencyInjectionGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,17 @@ private static string GenerateCustomHandlingSource(MethodModel method, Equatable
{
var invocations = string.Join("\n", customHandlers.Select(h =>
{
var genericArguments = string.Join(", ", h.TypeArguments);
var arguments = string.Join(", ", method.Parameters.Select(p => p.Name));
return $" {h.HandlerMethodName}<{genericArguments}>({arguments});";
if (h.CustomHandlerType == CustomHandlerType.Method)
{
var genericArguments = string.Join(", ", h.TypeArguments);
var arguments = string.Join(", ", method.Parameters.Select(p => p.Name));
return $" {h.HandlerMethodName}<{genericArguments}>({arguments});";
}
else
{
var arguments = string.Join(", ", method.Parameters.Select(p => p.Name));
return $" {h.TypeName}.{h.HandlerMethodName}({arguments});";
}
}));

var namespaceDeclaration = method.Namespace is null ? "" : $"namespace {method.Namespace};";
Expand Down
7 changes: 0 additions & 7 deletions ServiceScan.SourceGenerator/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,77 +4,70 @@

public static class DiagnosticDescriptors
{
public static readonly DiagnosticDescriptor NotPartialDefinition = new("DI0001",

Check warning on line 7 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0001' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Method is not partial",
"Method with GenerateServiceRegistrations attribute must have partial modifier",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor WrongReturnType = new("DI0002",

Check warning on line 14 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0002' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Wrong return type",
"Method with GenerateServiceRegistrations attribute must return void or IServiceCollection",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor WrongMethodParameters = new("DI0003",

Check warning on line 21 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0003' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Wrong method parameters",
"Method with GenerateServiceRegistrations attribute must have a single IServiceCollection parameter",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor MissingSearchCriteria = new("DI0004",

Check warning on line 28 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0004' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Missing search criteria",
"GenerateServiceRegistrations must have at least one search criteria",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor NoMatchingTypesFound = new("DI0005",

Check warning on line 35 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0005' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"No matching types found",
"There are no types matching attribute's search criteria",
"Usage",
DiagnosticSeverity.Warning,
true);

public static readonly DiagnosticDescriptor KeySelectorMethodHasIncorrectSignature = new("DI0007",

Check warning on line 42 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0007' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Provided KeySelector method has incorrect signature",
"KeySelector should have non-void return type, and either be generic with no parameters, or non-generic with a single Type parameter",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor CantMixRegularAndCustomHandlerRegistrations = new("DI0008",

Check warning on line 49 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0008' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"It's not allowed to mix GenerateServiceRegistrations attributes with and without CustomHandler on the same method",
"It's not allowed to mix GenerateServiceRegistrations attributes with and without CustomHandler on the same method",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor WrongReturnTypeForCustomHandler = new("DI0009",

Check warning on line 56 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0009' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Wrong return type",
"Method with CustomHandler must return void or the type of its first parameter",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor CustomHandlerMethodNotFound = new("DI0012",
"Provided CustomHandler method is not found",
"CustomHandler parameter should point to a method in the class",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor CustomHandlerMethodHasIncorrectSignature = new("DI0011",

Check warning on line 63 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0011' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Provided CustomHandler method has incorrect signature",
"CustomHandler method must be generic, and must have the same parameters as the method with the attribute",
"Usage",
DiagnosticSeverity.Error,
true);

public static readonly DiagnosticDescriptor CantUseBothFromAssemblyOfAndAssemblyNameFilter = new("DI0012",

Check warning on line 70 in ServiceScan.SourceGenerator/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / build

Enable analyzer release tracking for the analyzer project containing rule 'DI0012' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Only one assembly selection criteria allowed",
"It is not allowed to use both FromAssemblyOf and AssemblyNameFilter in the same attribute",
"Usage",
Expand Down
Loading