Skip to content

Commit d14cace

Browse files
Add ScanForTypesAttribute as dedicated attribute for custom type handling (#53)
* Initial plan * Add GenerateServiceHandlerAttribute and mark CustomHandler as Obsolete on GenerateServiceRegistrationsAttribute Co-authored-by: Dreamescaper <17177729+Dreamescaper@users.noreply.github.com> * Bump major version to 3.0 Co-authored-by: Dreamescaper <17177729+Dreamescaper@users.noreply.github.com> * Rename GenerateServiceHandlerAttribute to ScanForTypesAttribute, CustomHandler to Handler Co-authored-by: Dreamescaper <17177729+Dreamescaper@users.noreply.github.com> * Merge main and fix ScanForTypesAttribute to use [Embedded] instead of [Conditional] Co-authored-by: Dreamescaper <17177729+Dreamescaper@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Dreamescaper <17177729+Dreamescaper@users.noreply.github.com>
1 parent 970e164 commit d14cace

8 files changed

Lines changed: 447 additions & 21 deletions

File tree

README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@ public class HelloWorldEndpoint : IEndpoint
9393

9494
public static partial class ServiceCollectionExtensions
9595
{
96-
[GenerateServiceRegistrations(AssignableTo = typeof(IEndpoint), CustomHandler = nameof(IEndpoint.MapEndpoint))]
96+
[ScanForTypes(AssignableTo = typeof(IEndpoint), Handler = nameof(IEndpoint.MapEndpoint))]
9797
public static partial IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder endpoints);
9898
}
9999
```
100100

101101
### Register Options types
102-
Another example of `CustomHandler` is to register Options types. We can define custom `OptionAttribute`, which allows to specify configuration section key.
103-
And then read that value in our `CustomHandler`:
102+
Another example of `Handler` is to register Options types. We can define custom `OptionAttribute`, which allows to specify configuration section key.
103+
And then read that value in our `Handler`:
104104
```csharp
105105
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
106106
public class OptionAttribute(string? section = null) : Attribute
@@ -116,7 +116,7 @@ public record SectionOption { }
116116

117117
public static partial class ServiceCollectionExtensions
118118
{
119-
[GenerateServiceRegistrations(AttributeFilter = typeof(OptionAttribute), CustomHandler = nameof(AddOption))]
119+
[ScanForTypes(AttributeFilter = typeof(OptionAttribute), Handler = nameof(AddOption))]
120120
public static partial IServiceCollection AddOptions(this IServiceCollection services, IConfiguration configuration);
121121

122122
private static void AddOption<T>(IServiceCollection services, IConfiguration configuration) where T : class
@@ -133,7 +133,7 @@ public static partial class ServiceCollectionExtensions
133133
```csharp
134134
public static partial class ModelBuilderExtensions
135135
{
136-
[GenerateServiceRegistrations(AssignableTo = typeof(IEntityTypeConfiguration<>), CustomHandler = nameof(ApplyConfiguration))]
136+
[ScanForTypes(AssignableTo = typeof(IEntityTypeConfiguration<>), Handler = nameof(ApplyConfiguration))]
137137
public static partial ModelBuilder ApplyEntityConfigurations(this ModelBuilder modelBuilder);
138138

139139
private static void ApplyConfiguration<T, TEntity>(ModelBuilder modelBuilder)
@@ -164,4 +164,17 @@ public static partial class ModelBuilderExtensions
164164
| **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. |
165165
| **ExcludeByAttribute** | Excludes matching types by the specified attribute type being present. |
166166
| **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. |
167-
| **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. <br>**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). |
167+
| **CustomHandler** | *(Obsolete — use `ScanForTypes` instead.)* Sets this property to invoke a custom method for each type found instead of regular registration logic. |
168+
169+
`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`):
170+
| Property | Description |
171+
| --- | --- |
172+
| **Handler** | Sets this property to invoke a custom method for each type found. 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>**Note:** Types are automatically filtered by the generic constraints defined on the method's type parameters (e.g., `class`, `struct`, `new()`, interface constraints). |
173+
| **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. |
174+
| **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. |
175+
| **AssignableTo** | Sets the type that the scanned types must be assignable to. |
176+
| **ExcludeAssignableTo** | Sets the type that the scanned types must *not* be assignable to. |
177+
| **TypeNameFilter** | Sets this value to filter the types by their full name. You can use '*' wildcards. You can also use ',' to separate multiple filters. |
178+
| **AttributeFilter** | Filters types by the specified attribute type being present. |
179+
| **ExcludeByTypeName** | Sets this value to exclude types by their full name. You can use '*' wildcards. You can also use ',' to separate multiple filters. |
180+
| **ExcludeByAttribute** | Excludes matching types by the specified attribute type being present. |

ServiceScan.SourceGenerator.Tests/CustomHandlerTests.cs

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,4 +1099,248 @@ private static Compilation CreateCompilation(params string[] source)
10991099
],
11001100
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
11011101
}
1102+
1103+
[Fact]
1104+
public void ScanForTypesAttribute_WithNoParameters()
1105+
{
1106+
var source = $$"""
1107+
using ServiceScan.SourceGenerator;
1108+
1109+
namespace GeneratorTests;
1110+
1111+
public static partial class ServicesExtensions
1112+
{
1113+
[ScanForTypes(AssignableTo = typeof(IService), Handler = nameof(HandleType))]
1114+
public static partial void ProcessServices();
1115+
1116+
private static void HandleType<T>() => System.Console.WriteLine(typeof(T).Name);
1117+
}
1118+
""";
1119+
1120+
var services =
1121+
"""
1122+
namespace GeneratorTests;
1123+
1124+
public interface IService { }
1125+
public class MyService1 : IService { }
1126+
public class MyService2 : IService { }
1127+
""";
1128+
1129+
var compilation = CreateCompilation(source, services);
1130+
1131+
var results = CSharpGeneratorDriver
1132+
.Create(_generator)
1133+
.RunGenerators(compilation)
1134+
.GetRunResult();
1135+
1136+
var expected = $$"""
1137+
namespace GeneratorTests;
1138+
1139+
public static partial class ServicesExtensions
1140+
{
1141+
public static partial void ProcessServices()
1142+
{
1143+
HandleType<global::GeneratorTests.MyService1>();
1144+
HandleType<global::GeneratorTests.MyService2>();
1145+
}
1146+
}
1147+
""";
1148+
Assert.Equal(expected, results.GeneratedTrees[2].ToString());
1149+
}
1150+
1151+
[Fact]
1152+
public void ScanForTypesAttribute_WithParameters()
1153+
{
1154+
var source = $$"""
1155+
using ServiceScan.SourceGenerator;
1156+
1157+
namespace GeneratorTests;
1158+
1159+
public static partial class ServicesExtensions
1160+
{
1161+
[ScanForTypes(TypeNameFilter = "*Service", Handler = nameof(HandleType))]
1162+
public static partial void ProcessServices(string value);
1163+
1164+
private static void HandleType<T>(string value) => System.Console.WriteLine(value + typeof(T).Name);
1165+
}
1166+
""";
1167+
1168+
var services =
1169+
"""
1170+
namespace GeneratorTests;
1171+
1172+
public class MyFirstService {}
1173+
public class MySecondService {}
1174+
public class ServiceWithNonMatchingName {}
1175+
""";
1176+
1177+
var compilation = CreateCompilation(source, services);
1178+
1179+
var results = CSharpGeneratorDriver
1180+
.Create(_generator)
1181+
.RunGenerators(compilation)
1182+
.GetRunResult();
1183+
1184+
var expected = $$"""
1185+
namespace GeneratorTests;
1186+
1187+
public static partial class ServicesExtensions
1188+
{
1189+
public static partial void ProcessServices( string value)
1190+
{
1191+
HandleType<global::GeneratorTests.MyFirstService>(value);
1192+
HandleType<global::GeneratorTests.MySecondService>(value);
1193+
}
1194+
}
1195+
""";
1196+
Assert.Equal(expected, results.GeneratedTrees[2].ToString());
1197+
}
1198+
1199+
[Fact]
1200+
public void ScanForTypesAttribute_MultipleAttributes()
1201+
{
1202+
var source = $$"""
1203+
using ServiceScan.SourceGenerator;
1204+
1205+
namespace GeneratorTests;
1206+
1207+
public static partial class ServicesExtensions
1208+
{
1209+
[ScanForTypes(AssignableTo = typeof(IFirstService), Handler = nameof(HandleFirstType))]
1210+
[ScanForTypes(AssignableTo = typeof(ISecondService), Handler = nameof(HandleSecondType))]
1211+
public static partial void ProcessServices();
1212+
1213+
private static void HandleFirstType<T>() => System.Console.WriteLine("First:" + typeof(T).Name);
1214+
private static void HandleSecondType<T>() => System.Console.WriteLine("Second:" + typeof(T).Name);
1215+
}
1216+
""";
1217+
1218+
var services =
1219+
"""
1220+
namespace GeneratorTests;
1221+
1222+
public interface IFirstService { }
1223+
public interface ISecondService { }
1224+
public class MyService1 : IFirstService { }
1225+
public class MyService2 : ISecondService { }
1226+
""";
1227+
1228+
var compilation = CreateCompilation(source, services);
1229+
1230+
var results = CSharpGeneratorDriver
1231+
.Create(_generator)
1232+
.RunGenerators(compilation)
1233+
.GetRunResult();
1234+
1235+
var expected = $$"""
1236+
namespace GeneratorTests;
1237+
1238+
public static partial class ServicesExtensions
1239+
{
1240+
public static partial void ProcessServices()
1241+
{
1242+
HandleFirstType<global::GeneratorTests.MyService1>();
1243+
HandleSecondType<global::GeneratorTests.MyService2>();
1244+
}
1245+
}
1246+
""";
1247+
Assert.Equal(expected, results.GeneratedTrees[2].ToString());
1248+
}
1249+
1250+
[Fact]
1251+
public void ScanForTypesAttribute_MissingHandler_ReportsDiagnostic()
1252+
{
1253+
var source = $$"""
1254+
using ServiceScan.SourceGenerator;
1255+
1256+
namespace GeneratorTests;
1257+
1258+
public static partial class ServicesExtensions
1259+
{
1260+
[ScanForTypes(AssignableTo = typeof(IService))]
1261+
public static partial void ProcessServices();
1262+
}
1263+
""";
1264+
1265+
var services =
1266+
"""
1267+
namespace GeneratorTests;
1268+
1269+
public interface IService { }
1270+
public class MyService : IService { }
1271+
""";
1272+
1273+
var compilation = CreateCompilation(source, services);
1274+
1275+
var results = CSharpGeneratorDriver
1276+
.Create(_generator)
1277+
.RunGenerators(compilation)
1278+
.GetRunResult();
1279+
1280+
Assert.Equal(results.Diagnostics.Single().Descriptor, DiagnosticDescriptors.MissingCustomHandlerOnGenerateServiceHandler);
1281+
}
1282+
1283+
[Fact]
1284+
public void ScanForTypesAttribute_MissingSearchCriteria_ReportsDiagnostic()
1285+
{
1286+
var source = $$"""
1287+
using ServiceScan.SourceGenerator;
1288+
1289+
namespace GeneratorTests;
1290+
1291+
public static partial class ServicesExtensions
1292+
{
1293+
[ScanForTypes(Handler = nameof(HandleType))]
1294+
public static partial void ProcessServices();
1295+
1296+
private static void HandleType<T>() { }
1297+
}
1298+
""";
1299+
1300+
var compilation = CreateCompilation(source);
1301+
1302+
var results = CSharpGeneratorDriver
1303+
.Create(_generator)
1304+
.RunGenerators(compilation)
1305+
.GetRunResult();
1306+
1307+
Assert.Equal(results.Diagnostics.Single().Descriptor, DiagnosticDescriptors.MissingSearchCriteria);
1308+
}
1309+
1310+
[Fact]
1311+
public void MixingGenerateServiceRegistrationsAndScanForTypes_ReportsDiagnostic()
1312+
{
1313+
var source = $$"""
1314+
using ServiceScan.SourceGenerator;
1315+
using Microsoft.Extensions.DependencyInjection;
1316+
1317+
namespace GeneratorTests;
1318+
1319+
public static partial class ServicesExtensions
1320+
{
1321+
[GenerateServiceRegistrations(AssignableTo = typeof(IService))]
1322+
[ScanForTypes(AssignableTo = typeof(IService), Handler = nameof(HandleType))]
1323+
public static partial IServiceCollection ProcessServices(this IServiceCollection services);
1324+
1325+
private static void HandleType<T>() { }
1326+
}
1327+
""";
1328+
1329+
var services =
1330+
"""
1331+
namespace GeneratorTests;
1332+
1333+
public interface IService { }
1334+
public class MyService : IService { }
1335+
""";
1336+
1337+
var compilation = CreateCompilation(source, services);
1338+
1339+
var results = CSharpGeneratorDriver
1340+
.Create(_generator)
1341+
.RunGenerators(compilation)
1342+
.GetRunResult();
1343+
1344+
Assert.Contains(results.Diagnostics, d => d.Descriptor == DiagnosticDescriptors.CantMixServiceRegistrationsAndServiceHandler);
1345+
}
11021346
}

ServiceScan.SourceGenerator/DependencyInjectionGenerator.ParseMethodModel.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ public partial class DependencyInjectionGenerator
1717
if (!method.IsPartialDefinition)
1818
return Diagnostic.Create(NotPartialDefinition, method.Locations[0]);
1919

20+
// Check if ScanForTypesAttribute is also on this method - that's not allowed
21+
var hasServiceHandlerAttribute = method.GetAttributes()
22+
.Any(a => a.AttributeClass?.ToDisplayString() == GenerateAttributeInfo.HandlerMetadataName);
23+
24+
if (hasServiceHandlerAttribute)
25+
return Diagnostic.Create(CantMixServiceRegistrationsAndServiceHandler, method.Locations[0]);
26+
2027
var position = context.TargetNode.SpanStart;
2128
var attributeData = context.Attributes.Select(a => AttributeModel.Create(a, method, context.SemanticModel)).ToArray();
2229
var hasCustomHandlers = attributeData.Any(a => a.CustomHandler != null);
@@ -94,4 +101,63 @@ public partial class DependencyInjectionGenerator
94101
var model = MethodModel.Create(method, context.TargetNode);
95102
return new MethodWithAttributesModel(model, [.. attributeData]);
96103
}
104+
105+
private static DiagnosticModel<MethodWithAttributesModel>? ParseHandlerMethodModel(GeneratorAttributeSyntaxContext context)
106+
{
107+
if (context.TargetSymbol is not IMethodSymbol method)
108+
return null;
109+
110+
if (!method.IsPartialDefinition)
111+
return Diagnostic.Create(NotPartialDefinition, method.Locations[0]);
112+
113+
// Skip if this method also has GenerateServiceRegistrationsAttribute - that provider reports the mixing error
114+
var hasServiceRegistrationsAttribute = method.GetAttributes()
115+
.Any(a => a.AttributeClass?.ToDisplayString() == GenerateAttributeInfo.MetadataName);
116+
117+
if (hasServiceRegistrationsAttribute)
118+
return null;
119+
120+
var position = context.TargetNode.SpanStart;
121+
var attributeData = context.Attributes.Select(a => AttributeModel.Create(a, method, context.SemanticModel)).ToArray();
122+
123+
foreach (var attribute in attributeData)
124+
{
125+
if (attribute.CustomHandler == null)
126+
return Diagnostic.Create(MissingCustomHandlerOnGenerateServiceHandler, attribute.Location);
127+
128+
if (!attribute.HasSearchCriteria)
129+
return Diagnostic.Create(MissingSearchCriteria, attribute.Location);
130+
131+
if (attribute.AssemblyOfTypeName != null && attribute.AssemblyNameFilter != null)
132+
return Diagnostic.Create(CantUseBothFromAssemblyOfAndAssemblyNameFilter, attribute.Location);
133+
134+
var customHandlerMethod = method.ContainingType.GetMethod(attribute.CustomHandler, context.SemanticModel, position);
135+
136+
if (customHandlerMethod != null)
137+
{
138+
if (!customHandlerMethod.IsGenericMethod)
139+
return Diagnostic.Create(CustomHandlerMethodHasIncorrectSignature, attribute.Location);
140+
141+
var typesMatch = Enumerable.SequenceEqual(
142+
method.Parameters.Select(p => p.Type),
143+
customHandlerMethod.Parameters.Select(p => p.Type),
144+
SymbolEqualityComparer.Default);
145+
146+
if (!typesMatch)
147+
return Diagnostic.Create(CustomHandlerMethodHasIncorrectSignature, attribute.Location);
148+
}
149+
150+
if (attribute.HasErrors)
151+
return null;
152+
}
153+
154+
if (!method.ReturnsVoid &&
155+
(method.Parameters.Length == 0 || !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, method.ReturnType)))
156+
{
157+
return Diagnostic.Create(WrongReturnTypeForCustomHandler, method.Locations[0]);
158+
}
159+
160+
var model = MethodModel.Create(method, context.TargetNode);
161+
return new MethodWithAttributesModel(model, [.. attributeData]);
162+
}
97163
}

0 commit comments

Comments
 (0)