diff --git a/.github/workflows/ioc.benchmark.nativeaot.yml b/.github/workflows/ioc.benchmark.nativeaot.yml index 3370fbf..21ddd67 100644 --- a/.github/workflows/ioc.benchmark.nativeaot.yml +++ b/.github/workflows/ioc.benchmark.nativeaot.yml @@ -4,28 +4,8 @@ on: workflow_dispatch: push: - branches: [main] - paths: - - 'src/Ioc/src/SourceGen.Ioc/**' - - 'src/Ioc/src/SourceGen.Ioc.SourceGenerator/**' - - 'src/Ioc/test/SourceGen.Ioc.Benchmark/**' - - 'src/Directory.Build.props' - - 'Directory.Build.props' - - 'Directory.Packages.props' - - 'global.json' - - '.github/workflows/ioc.benchmark.nativeaot.yml' - - pull_request: - branches: [main] - paths: - - 'src/Ioc/src/SourceGen.Ioc/**' - - 'src/Ioc/src/SourceGen.Ioc.SourceGenerator/**' - - 'src/Ioc/test/SourceGen.Ioc.Benchmark/**' - - 'src/Directory.Build.props' - - 'Directory.Build.props' - - 'Directory.Packages.props' - - 'global.json' - - '.github/workflows/ioc.benchmark.nativeaot.yml' + tags: + - 'ioc-v*' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/docs/Ioc/04_Field_Property_Method_Injection.md b/docs/Ioc/04_Field_Property_Method_Injection.md index 8c34243..b31a4b5 100644 --- a/docs/Ioc/04_Field_Property_Method_Injection.md +++ b/docs/Ioc/04_Field_Property_Method_Injection.md @@ -131,8 +131,32 @@ services.AddSingleton((global::System.IServicePr |ID|Severity|Description| |:---|:---|:---| -|SGIOC007|Error|Invalid `[IocInject]` usage. The attribute cannot be applied to static members, inaccessible members (private setter, no setter, private field, readonly field, private method), or methods that do not return `void`.| +|SGIOC007|Error|Invalid `[IocInject]` usage. The attribute cannot be applied to static members, non-accessible members (`private`, `protected`, `private protected` — but `protected internal` is accepted), properties without a setter or with an inaccessible setter, `readonly` fields, generic methods, non-ordinary methods (e.g., constructors, operators), or methods that do not return `void`.| |SGIOC022|Warning|`[IocInject]` is ignored when the corresponding feature (`PropertyInject`, `FieldInject`, or `MethodInject`) is disabled in `SourceGenIocFeatures`.| +|SGIOC023|Error|An element in the `InjectMembers` array is not in a recognized format. Each element must be `nameof(member)` or `new object[] { nameof(member), key [, KeyType] }`.| +|SGIOC024|Error|A member specified via `InjectMembers` is not injectable (e.g., static, non-accessible members (`private`, `protected`, `private protected` — but `protected internal` is accepted), no setter or inaccessible setter, `readonly` field, generic method, non-ordinary method, or method that does not return `void`).| + +## InjectMembers: Attribute-Level Injection Without `[IocInject]` + +When you cannot add `[IocInject]` directly to a type's members (e.g., a third-party type), use the `InjectMembers` property on `[IocRegisterFor]` to specify injection points from the registration site: + +```csharp +// Register ThirdPartyService without modifying it +[IocRegisterFor(typeof(ThirdPartyService), + InjectMembers = [nameof(ThirdPartyService.Logger)])] +public static class ThirdPartyModule { } +``` + +Each element is one of: + +| Format | Description | +| :--- | :--- | +| `nameof(T.Member)` | Inject without a key (resolves `T` from the container) | +| `new object[] { nameof(T.Member), "key" }` | Inject a keyed service | +| `new object[] { nameof(T.Member), nameof(SomeKey), KeyType.Csharp }` | Inject using a C# expression key | + +> [!NOTE] +> When the same member is specified in both `InjectMembers` and via `[IocInject]` on the member itself, `[IocInject]` takes priority. --- diff --git a/docs/Ioc/08_Factory_Instance.md b/docs/Ioc/08_Factory_Instance.md index f94e8f3..1eb3a57 100644 --- a/docs/Ioc/08_Factory_Instance.md +++ b/docs/Ioc/08_Factory_Instance.md @@ -249,7 +249,7 @@ services.AddScoped((global::System.IServiceProvi When working with open generic services, you can use a **generic factory method** to create instances. This requires the `[IocGenericFactory]` attribute to map service type placeholders to factory method type parameters. > [!WARNING] -> If `Factory` points to a generic method but that method does not have `[IocGenericFactory]`, analyzer `SGIOC016` is reported and registration will not be generated. +> If `Factory` points to a generic method but that method does not have `[IocGenericFactory]` and no valid `GenericFactoryTypeMapping` is specified on the registration attribute, analyzer `SGIOC016` is reported and registration will not be generated. When using `GenericFactoryTypeMapping`, the number of placeholder types must match the factory method's type parameter count. ### Basic Generic Factory @@ -409,6 +409,34 @@ services.AddSingleton +## Generic Factory Without `[IocGenericFactory]` + +As an alternative to placing `[IocGenericFactory]` on the factory method, you can specify the type mapping directly on the registration attribute via `GenericFactoryTypeMapping`. This is useful when you cannot modify the factory method (e.g., a third-party library). + +```csharp +// No [IocGenericFactory] attribute needed on the factory method +[assembly: IocRegisterDefaults( + typeof(IRequestHandler<>), + ServiceLifetime.Singleton, + Factory = nameof(ExternalFactory.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler>), typeof(int)])] + +public static class ExternalFactory +{ + // Third-party or shared factory — cannot be modified + public static IRequestHandler> Create() + => throw new NotImplementedException(); +} +``` + +The `GenericFactoryTypeMapping` property uses the same format as `[IocGenericFactory]`: + +- **First element**: the service type template with concrete placeholder types (e.g., `typeof(IRequestHandler>)`) +- **Remaining elements**: the placeholder types that map to factory method type parameters in order + +> [!NOTE] +> `GenericFactoryTypeMapping` is available on `[IocRegisterDefaults]` and `[IocRegisterFor]`. When both `GenericFactoryTypeMapping` on the attribute and `[IocGenericFactory]` on the method are present, `[IocGenericFactory]` takes precedence. + ## Diagnostics |ID|Severity|Description| @@ -416,8 +444,8 @@ services.AddSingleton #pragma warning disable 1591 namespace IocRazorSample @@ -10,19 +10,19 @@ namespace IocRazorSample using global::System.Threading.Tasks; using global::Microsoft.AspNetCore.Components; #nullable restore -#line (1,2)-(1,43) "C:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" +#line (1,2)-(1,43) "c:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" using Microsoft.AspNetCore.Components.Web #nullable disable ; #nullable restore -#line (2,2)-(2,21) "C:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" +#line (2,2)-(2,21) "c:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" using SourceGen.Ioc #nullable disable ; #nullable restore -#line (3,2)-(3,24) "C:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" +#line (3,2)-(3,24) "c:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" using IocSample.Shared #nullable disable diff --git a/samples/Ioc/IocRazorSample/Generated/Microsoft.CodeAnalysis.Razor.Compiler/Microsoft.NET.Sdk.Razor.SourceGenerators.RazorSourceGenerator/_Imports_razor.g.cs b/samples/Ioc/IocRazorSample/Generated/Microsoft.CodeAnalysis.Razor.Compiler/Microsoft.NET.Sdk.Razor.SourceGenerators.RazorSourceGenerator/_Imports_razor.g.cs index 21d6a52..2f23847 100644 --- a/samples/Ioc/IocRazorSample/Generated/Microsoft.CodeAnalysis.Razor.Compiler/Microsoft.NET.Sdk.Razor.SourceGenerators.RazorSourceGenerator/_Imports_razor.g.cs +++ b/samples/Ioc/IocRazorSample/Generated/Microsoft.CodeAnalysis.Razor.Compiler/Microsoft.NET.Sdk.Razor.SourceGenerators.RazorSourceGenerator/_Imports_razor.g.cs @@ -1,4 +1,4 @@ -#pragma checksum "C:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" "{8829d00f-11b8-4213-878b-770e8597ac16}" "550f1173c09036256c057d57fd1381095e3d16eb6d45624996c5f140f0d8357e" +#pragma checksum "c:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" "{8829d00f-11b8-4213-878b-770e8597ac16}" "550f1173c09036256c057d57fd1381095e3d16eb6d45624996c5f140f0d8357e" // #pragma warning disable 1591 namespace IocRazorSample @@ -10,19 +10,19 @@ namespace IocRazorSample using global::System.Threading.Tasks; using global::Microsoft.AspNetCore.Components; #nullable restore -#line (1,2)-(1,43) "C:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" +#line (1,2)-(1,43) "c:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" using Microsoft.AspNetCore.Components.Web #nullable disable ; #nullable restore -#line (2,2)-(2,21) "C:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" +#line (2,2)-(2,21) "c:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" using SourceGen.Ioc #nullable disable ; #nullable restore -#line (3,2)-(3,24) "C:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" +#line (3,2)-(3,24) "c:\Users\andy0\source\repos\SourceGen\samples\Ioc\IocRazorSample\_Imports.razor" using IocSample.Shared #nullable disable diff --git a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.AttributeUsage.cs b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.AttributeUsage.cs index 080ddb3..f289574 100644 --- a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.AttributeUsage.cs +++ b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.AttributeUsage.cs @@ -90,91 +90,20 @@ private static void AnalyzeInjectAttribute(SymbolAnalysisContext context, Analyz featureName)); } - // Check if member is static - if (member.IsStatic) + // Container-class partial definitions are valid injection targets (keyed service accessors) + if (member is IPropertySymbol { IsPartialDefinition: true } && IsInContainerClass(member.ContainingType)) + return; + if (member is IMethodSymbol { IsPartialDefinition: true } && IsInContainerClass(member.ContainingType)) + return; + + var reason = GetMemberInjectabilityIssue(member); + if (reason is not null) { context.ReportDiagnostic(Diagnostic.Create( InvalidInjectAttributeUsage, location, member.Name, - "it is static")); - return; - } - - switch (member) - { - case IPropertySymbol property: - // Allow [IocInject] on partial properties in [IocContainer] classes (for keyed service accessor) - if (property.IsPartialDefinition && IsInContainerClass(member.ContainingType)) - return; - - // Check if property has no setter or setter is private - if (property.SetMethod is null) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidInjectAttributeUsage, - location, - member.Name, - "property has no setter")); - } - else if (property.SetMethod.DeclaredAccessibility is Accessibility.Private) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidInjectAttributeUsage, - location, - member.Name, - "property setter is private")); - } - - break; - - case IFieldSymbol field: - // Check if field is readonly - if (field.IsReadOnly) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidInjectAttributeUsage, - location, - member.Name, - "field is readonly")); - } - // Check if field is private - else if (field.DeclaredAccessibility is Accessibility.Private) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidInjectAttributeUsage, - location, - member.Name, - "field is private")); - } - - break; - - case IMethodSymbol method: - // Allow [IocInject] on partial methods in [IocContainer] classes (for keyed service accessor) - if (method.IsPartialDefinition && IsInContainerClass(member.ContainingType)) - return; - - // Check if method is private - if (method.DeclaredAccessibility is Accessibility.Private) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidInjectAttributeUsage, - location, - member.Name, - "method is private")); - } - // Check if method does not return void - else if (!method.ReturnsVoid) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidInjectAttributeUsage, - location, - member.Name, - "method must return void")); - } - - break; + reason)); } } @@ -225,6 +154,12 @@ private static void AnalyzeFactoryAndInstanceOnAttribute( { AnalyzeNameofMemberOnSyntax(context, argumentList, location, "Instance"); } + + // SGIOC023 + SGIOC024: Validate InjectMembers elements - only for IoCRegisterFor attributes + if (!isDefaultsAttribute) + { + AnalyzeInjectMembersOnAttribute(context, argumentList); + } } /// @@ -321,7 +256,62 @@ invocation.Expression is not IdentifierNameSyntax identifierName || } } - if (!hasIocGenericFactory) + // Suppress SGIOC016 if the registration attribute provides GenericFactoryTypeMapping + var hasGenericFactoryTypeMappingOnAttr = false; + // Always check GenericFactoryTypeMapping for duplicate placeholders (SGIOC017), regardless of [IocGenericFactory] + foreach (var arg in argumentList.Arguments) + { + if (arg.NameEquals?.Name.Identifier.Text == "GenericFactoryTypeMapping") + { + ExpressionSyntax[] mappingElements = arg.Expression switch + { + CollectionExpressionSyntax coll => [.. coll.Elements.OfType().Select(static e => e.Expression)], + ArrayCreationExpressionSyntax { Initializer: not null } arr => [.. arr.Initializer.Expressions], + ImplicitArrayCreationExpressionSyntax implicitArr => [.. implicitArr.Initializer.Expressions], + _ => [] + }; + + if (mappingElements.Length >= 2) + { + // Check for duplicate placeholder types (index 1+) + var seenTypes = new HashSet(SymbolEqualityComparer.Default); + ITypeSymbol? duplicateType = null; + for (int i = 1; i < mappingElements.Length; i++) + { + if (mappingElements[i] is TypeOfExpressionSyntax typeofExpr) + { + var typeInfo = context.SemanticModel.GetTypeInfo(typeofExpr.Type, context.CancellationToken); + if (typeInfo.Type is { } placeholderType) + { + if (!seenTypes.Add(placeholderType)) + { + duplicateType = placeholderType; + break; + } + } + } + } + + if (duplicateType is not null) + { + // Duplicate placeholders: report SGIOC017 + context.ReportDiagnostic(Diagnostic.Create( + DuplicatedGenericFactoryPlaceholders, + arg.Expression.GetLocation(), + duplicateType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + } + else if (!hasIocGenericFactory && mappingElements.Length - 1 == methodSymbol.TypeParameters.Length) + { + // Only suppress SGIOC016 when [IocGenericFactory] is NOT present + // AND the mapping provides exactly one placeholder per factory type parameter + hasGenericFactoryTypeMappingOnAttr = true; + } + } + break; + } + } + + if (!hasIocGenericFactory && !hasGenericFactoryTypeMappingOnAttr) { context.ReportDiagnostic(Diagnostic.Create( GenericFactoryMissingAttribute, @@ -371,14 +361,12 @@ private static void AnalyzeIocGenericFactoryAttribute(SymbolAnalysisContext cont return; // Need at least service type template and one placeholder // Check for duplicates in placeholder types (from index 1 to end) - var seenTypes = new HashSet(StringComparer.Ordinal); + var seenTypes = new HashSet(SymbolEqualityComparer.Default); for (int i = 1; i < typeArray.Length; i++) { if (typeArray[i].Value is ITypeSymbol placeholderType) { - var typeName = placeholderType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - if (!seenTypes.Add(typeName)) + if (!seenTypes.Add(placeholderType)) { // Duplicate found var location = genericFactoryAttr.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation(); @@ -470,4 +458,163 @@ private static bool IsInContainerClass(INamedTypeSymbol? containingType) return false; } + + /// + /// SGIOC023 + SGIOC024: Validates InjectMembers array elements on a registration attribute. + /// SGIOC023: Fires when an element is not nameof(...) or a valid array literal { nameof(...), key [, KeyType] }. + /// SGIOC024: Fires when the resolved member is not injectable. + /// + private static void AnalyzeInjectMembersOnAttribute( + SyntaxNodeAnalysisContext context, + AttributeArgumentListSyntax argumentList) + { + AttributeArgumentSyntax? injectMembersArg = null; + foreach (var arg in argumentList.Arguments) + { + if (arg.NameEquals?.Name.Identifier.Text == "InjectMembers") + { + injectMembersArg = arg; + break; + } + } + + if (injectMembersArg is null) + return; + + var elements = GetInjectMembersElements(injectMembersArg.Expression); + if (elements is null || elements.Length == 0) + return; + + for (int i = 0; i < elements.Length; i++) + { + var element = elements[i]; + ExpressionSyntax? nameofExpr = null; + + if (IsNameofExpression(element)) + { + // Format: nameof(Member) + nameofExpr = element; + } + else + { + // Format: { nameof(Member), key [, KeyType] } + var nested = GetInjectMembersElements(element); + if (nested is null || nested.Length < 2 || !IsNameofExpression(nested[0])) + { + context.ReportDiagnostic(Diagnostic.Create( + InjectMembersInvalidFormat, + element.GetLocation(), + i)); + continue; + } + + // Reject arrays with more than 3 elements + if (nested.Length > 3) + { + context.ReportDiagnostic(Diagnostic.Create( + InjectMembersInvalidFormat, + element.GetLocation(), + i)); + continue; + } + + // If 3 elements, validate 3rd is a valid KeyType constant (Value=0 or Csharp=1) + if (nested.Length == 3) + { + var ktConst = context.SemanticModel.GetConstantValue(nested[2], context.CancellationToken); + if (!ktConst.HasValue || ktConst.Value is not int ktVal || ktVal is not (0 or 1)) + { + context.ReportDiagnostic(Diagnostic.Create( + InjectMembersInvalidFormat, + element.GetLocation(), + i)); + continue; + } + } + + nameofExpr = nested[0]; + } + + // Resolve the member symbol from nameof(...) + if (nameofExpr is not InvocationExpressionSyntax nameofInvocation) + continue; + + var inner = nameofInvocation.ArgumentList.Arguments[0].Expression; + var symbolInfo = context.SemanticModel.GetSymbolInfo(inner, context.CancellationToken); + var symbol = symbolInfo.Symbol ?? symbolInfo.CandidateSymbols.FirstOrDefault(); + + if (symbol is null) + continue; // unresolvable — a compile error will already be reported + + // SGIOC024: Check if member is injectable + var (isInjectable, reason) = ValidateInjectableMember(symbol); + if (!isInjectable) + { + context.ReportDiagnostic(Diagnostic.Create( + InjectMembersNonInjectableMember, + nameofExpr.GetLocation(), + symbol.Name, + reason)); + } + } + } + + /// + /// Returns the reason a member is not injectable, or if it is valid. + /// Shared by SGIOC007 () and SGIOC024 (). + /// + private static string? GetMemberInjectabilityIssue(ISymbol symbol) + { + if (symbol.IsStatic) + return "it is static"; + + return symbol switch + { + IPropertySymbol p when p.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal) + => "property is not accessible", + IPropertySymbol { SetMethod: null } => "property has no setter", + IPropertySymbol p when p.SetMethod!.DeclaredAccessibility is Accessibility.Private + => "property setter is private", + IPropertySymbol p when p.SetMethod!.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal) + => "property setter is not accessible", + IFieldSymbol { IsReadOnly: true } => "field is readonly", + IFieldSymbol f when f.DeclaredAccessibility is Accessibility.Private + => "field is private", + IFieldSymbol f when f.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal) + => "field is not accessible", + IMethodSymbol { MethodKind: MethodKind.Constructor } => null, + IMethodSymbol m when m.DeclaredAccessibility is Accessibility.Private + => "method is private", + IMethodSymbol m when m.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal) + => "method is not accessible", + IMethodSymbol m when m.MethodKind != MethodKind.Ordinary + => "method is not an ordinary method", + IMethodSymbol { ReturnsVoid: false } => "method does not return void", + IMethodSymbol { IsGenericMethod: true } => "method is generic", + IPropertySymbol or IFieldSymbol or IMethodSymbol => null, + _ => "member is not a property, field, or method" + }; + } + + private static (bool IsInjectable, string Reason) ValidateInjectableMember(ISymbol symbol) + { + var reason = GetMemberInjectabilityIssue(symbol); + return reason is null ? (true, string.Empty) : (false, reason); + } + + private static ExpressionSyntax[]? GetInjectMembersElements(ExpressionSyntax expression) + => expression switch + { + ArrayCreationExpressionSyntax { Initializer: not null } arr + => [.. arr.Initializer.Expressions], + ImplicitArrayCreationExpressionSyntax implicitArr + => [.. implicitArr.Initializer.Expressions], + CollectionExpressionSyntax coll + => [.. coll.Elements.OfType().Select(static e => e.Expression)], + _ => null + }; + + private static bool IsNameofExpression(ExpressionSyntax expression) + => expression is InvocationExpressionSyntax { Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } } inv + && inv.ArgumentList.Arguments.Count == 1; } diff --git a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.cs b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.cs index 6f814a9..04d394e 100644 --- a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.cs +++ b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/RegisterAnalyzer.cs @@ -209,11 +209,11 @@ public sealed partial class RegisterAnalyzer : DiagnosticAnalyzer public static readonly DiagnosticDescriptor DuplicatedGenericFactoryPlaceholders = new( id: "SGIOC017", title: "Generic Factory Method's type parameters are duplicated", - messageFormat: "[IocGenericFactory] has duplicated placeholder type '{0}'; each placeholder type must be unique", + messageFormat: "[IocGenericFactory] or GenericFactoryTypeMapping has duplicated placeholder type '{0}'; each placeholder type must be unique", category: Constants.Category_Design, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "The placeholder types in [IocGenericFactory] (from second to last) must be unique. Duplicated types make it impossible to distinguish which type argument maps to which factory method type parameter."); + description: "The placeholder types in [IocGenericFactory] or GenericFactoryTypeMapping (from second to last) must be unique. Duplicated types make it impossible to distinguish which type argument maps to which factory method type parameter."); /// /// SGIOC022: Inject attribute ignored due to disabled feature. @@ -227,6 +227,30 @@ public sealed partial class RegisterAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, description: "When SourceGenIocFeatures disables a member injection feature, [IocInject] on that member is ignored during generation."); + /// + /// SGIOC023: Invalid InjectMembers element format. + /// + public static readonly DiagnosticDescriptor InjectMembersInvalidFormat = new( + id: "SGIOC023", + title: "Invalid InjectMembers element format", + messageFormat: "InjectMembers element at index {0} is invalid; expected nameof(member) or new object[] {{ nameof(member), key [, KeyType] }}", + category: Constants.Category_Usage, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Each element in InjectMembers must be either a nameof() expression or an array literal with member name, optional key, and optional KeyType."); + + /// + /// SGIOC024: InjectMembers specifies non-injectable member. + /// + public static readonly DiagnosticDescriptor InjectMembersNonInjectableMember = new( + id: "SGIOC024", + title: "InjectMembers specifies non-injectable member", + messageFormat: "Member '{0}' specified in InjectMembers cannot be injected: {1}", + category: Constants.Category_Usage, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Members specified in InjectMembers must be injectable: instance properties with accessible setters, non-readonly fields, and ordinary non-generic void-returning methods, all of which must be public, internal, or protected internal."); + public override ImmutableArray SupportedDiagnostics { get; } = [ InvalidAttributeUsage, @@ -246,7 +270,9 @@ public sealed partial class RegisterAnalyzer : DiagnosticAnalyzer KeyValuePairKeyTypeMismatch, GenericFactoryMissingAttribute, DuplicatedGenericFactoryPlaceholders, - InjectFeatureDisabled + InjectFeatureDisabled, + InjectMembersInvalidFormat, + InjectMembersNonInjectableMember ]; public override void Initialize(AnalysisContext context) diff --git a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/Spec/SPEC.md b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/Spec/SPEC.md index 6a210b3..62beda0 100644 --- a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/Spec/SPEC.md +++ b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Analyzer/Spec/SPEC.md @@ -83,21 +83,24 @@ Report when `FromKeyedServicesAttribute` and `IocInjectAttribute`/`InjectAttribu Report when `IocInjectAttribute`/`InjectAttribute` is mark on: - static member -- private setter -- setter not exists -- private field +- member without public, internal, or protected internal accessibility +- property without setter or with private setter - readonly field -- private method -- method is not return void. +- method that does not return void +- method that is generic (has type parameters) +- method that is not an ordinary method (e.g., constructor, operator) **Analysis:** - Checks members (properties, fields, methods) marked with `[IocInject]` or `[Inject]`. - Reports when: - Member is static. + - Member is not `public`, `internal`, or `protected internal` (private, protected, or private protected members are rejected because generated code runs in a public static context). - Property has no setter or setter is private. - - Field is readonly or private. - - Method is private or does not return void. + - Field is readonly. + - Method does not return void. + - Method is generic (has type parameters). + - Method is not an ordinary method (i.e., constructors, operators, and other special methods are rejected). --- @@ -228,7 +231,8 @@ Report when: - Checks Factory member specified via `nameof()` on `[IocRegister]`, `[IoCRegisterFor]`, or `[IoCRegisterDefaults]` attributes. - When the Factory references a method symbol, checks if the method is generic (has type parameters). - If the method is generic, checks if it has `[IocGenericFactory]` attribute. -- Reports when the factory method is generic but does not have `[IocGenericFactory]` attribute. +- The diagnostic does NOT fire if `GenericFactoryTypeMapping` is provided on the registration attribute (`IocRegisterForAttribute` or `IocRegisterDefaultsAttribute`) AND the number of placeholder types (mapping array length minus 1) equals the factory method's type parameter count. +- Reports when the factory method is generic but neither `[IocGenericFactory]` attribute on the method NOR a valid `GenericFactoryTypeMapping` on the registration attribute provides the type mapping. --- @@ -237,13 +241,17 @@ Report when: Report when: - `[IocGenericFactory]`'s type parameters from second to end have duplicated types, they must be unique. +- `GenericFactoryTypeMapping` property on `[IocRegisterFor]` or `[IocRegisterDefaults]` attribute contains duplicate placeholder types. **Analysis:** - Checks methods marked with `[IocGenericFactory]` attribute. -- Extracts the type array from the attribute's constructor arguments. -- Starting from the second type (index 1), checks if any type appears more than once. -- Reports when duplicate placeholder types are found, as each type must uniquely map to a factory method type parameter. + - Extracts the type array from the attribute's constructor arguments. + - Starting from the second type (index 1), checks if any type appears more than once. + - Reports when duplicate placeholder types are found, as each type must uniquely map to a factory method type parameter. +- Checks the `GenericFactoryTypeMapping` property on `[IocRegisterFor]` or `[IocRegisterDefaults]` attributes. + - Validates that all placeholder types in the mapping are unique. + - Reports when duplicate placeholder types are detected in the mapping. --- @@ -334,3 +342,41 @@ Report when a member has `[IocInject]`/`[Inject]` but its corresponding feature - Reports when the required feature flag is not enabled. **Message format:** `'{MemberName}' has [IocInject] but {FeatureName} feature is not enabled. Add '{FeatureName}' to in your project file.` + +--- + +### SGIOC023 - Error - Usage - Invalid InjectMembers element format + +Report when an element in the `InjectMembers` array is not in a recognized format. + +**Analysis:** + +- Checks the `InjectMembers` named argument array on `[IocRegisterFor]` registration attributes. +- Validates each array element: + - Must be one of: + - A `nameof()` expression resolving to a valid member + - A `new object[]` with exactly 2 or 3 elements where: + - First element is a `nameof()` expression + - Second element is a key (of any type) + - Optional third element is a `KeyType` enum constant (`KeyType.Value` or `KeyType.Csharp`) + - Arrays with more than 3 elements are rejected. + - If 3 elements, the third must be a valid `KeyType` enum constant. +- Reports when an element does not match one of the recognized formats. + +--- + +### SGIOC024 - Error - Usage - Member specified in InjectMembers cannot be injected + +Report when a member resolved from `nameof()` in `InjectMembers` cannot be injected. + +**Analysis:** + +- Checks members specified via `nameof()` in the `InjectMembers` array. +- Reports when the member is: + - static + - not `public`, `internal`, or `protected internal` (private, protected, or private protected members are rejected because generated registration code runs in a public static context) + - property without setter or with private setter + - readonly field + - method that doesn't return void or is generic + - method that is not an ordinary method (i.e., constructors, operators, and other special methods are rejected) +- This validation reuses the same logic as SGIOC007 but specifically for members specified via `InjectMembers`. diff --git a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/AnalyzerReleases.Unshipped.md b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/AnalyzerReleases.Unshipped.md index 150c36e..66c8429 100644 --- a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -24,4 +24,6 @@ SGIOC019 | Usage | Error | Container class must be partial - The class marked wi SGIOC020 | Usage | Warning | UseSwitchStatement ignored with imported modules - UseSwitchStatement = true is ignored when container has IocImportModule attributes. SGIOC021 | Design | Error | Unable to resolve partial accessor service - A partial method/property return type cannot be resolved when IntegrateServiceProvider is false. SGIOC022 | Usage | Warning | Inject attribute ignored due to disabled feature - [IocInject]/[Inject] on property/field/method is ignored when its SourceGenIocFeatures flag is not enabled. +SGIOC023 | Usage | Error | Invalid InjectMembers element format - Each element in InjectMembers must be nameof(member) or new object[] { nameof(member), key [, KeyType] }. +SGIOC024 | Usage | Error | InjectMembers specifies non-injectable member - Members in InjectMembers must be injectable (instance properties with accessible setters, non-readonly fields, and ordinary non-generic void-returning methods, all of which must be public, internal, or protected internal). diff --git a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Generator/TransformRegister.cs b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Generator/TransformRegister.cs index 9cb93d5..c53ddce 100644 --- a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Generator/TransformRegister.cs +++ b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Generator/TransformRegister.cs @@ -90,8 +90,7 @@ private static RegistrationData ExtractRegistrationData(INamedTypeSymbol typeSym factory = attributeData.GetFactoryMethodData(semanticModel); } - // Extract injection members (properties, fields, methods marked with IocInjectAttribute/InjectAttribute) - var injectionMembers = ExtractInjectionMembers(typeSymbol, semanticModel); + var injectionMembers = ExtractAndMergeInjectionMembers(typeSymbol, attributeData, semanticModel); // Build set of valid open generic service types (non-nested) for quick lookup var validOpenGenericServiceTypes = BuildValidOpenGenericServiceTypes( @@ -150,8 +149,7 @@ private static RegistrationData ExtractRegistrationDataFromGenericAttribute( factory = attributeData.GetFactoryMethodData(semanticModel); } - // Extract injection members (properties, fields, methods marked with InjectAttribute) - var injectionMembers = ExtractInjectionMembers(typeSymbol, semanticModel); + var injectionMembers = ExtractAndMergeInjectionMembers(typeSymbol, attributeData, semanticModel); // Build set of valid open generic service types (non-nested) for quick lookup var validOpenGenericServiceTypes = BuildValidOpenGenericServiceTypes( @@ -390,4 +388,226 @@ private static ImmutableEquatableSet BuildValidOpenGenericServiceTypes( return result.ToImmutableEquatableSet(); } + + /// + /// Extracts and merges injection members from both [IocInject] attributes on the type + /// and the InjectMembers attribute property. [IocInject] takes priority. + /// + private static ImmutableEquatableArray ExtractAndMergeInjectionMembers( + INamedTypeSymbol typeSymbol, AttributeData attributeData, SemanticModel? semanticModel) + { + var iocInjectMembers = ExtractInjectionMembers(typeSymbol, semanticModel); + + ImmutableEquatableArray attrInjectMembers = semanticModel is not null + ? ExtractInjectMembersFromAttribute(attributeData, semanticModel) + : []; + + return MergeInjectionMembers(iocInjectMembers, attrInjectMembers); + } + + /// + /// Merges injection members from [IocInject] attributes and the InjectMembers attribute property. + /// [IocInject] on the member takes priority: if the same member appears in both, the [IocInject] entry wins. + /// + private static ImmutableEquatableArray MergeInjectionMembers( + ImmutableEquatableArray iocInjectMembers, + ImmutableEquatableArray attributeMembers) + { + if(attributeMembers.Length == 0) + return iocInjectMembers; + + if(iocInjectMembers.Length == 0) + return attributeMembers; + + // [IocInject] takes priority – only add attribute members whose name is not already in [IocInject] set + var iocInjectNames = new HashSet(StringComparer.Ordinal); + foreach(var m in iocInjectMembers) + iocInjectNames.Add(m.Name); + + List merged = new(iocInjectMembers); + foreach(var m in attributeMembers) + { + if(!iocInjectNames.Contains(m.Name)) + merged.Add(m); + } + + return merged.ToImmutableEquatableArray(); + } + + /// + /// Extracts injection members from the registration attribute's InjectMembers property. + /// Uses + to resolve nameof() expressions. + /// + private static ImmutableEquatableArray ExtractInjectMembersFromAttribute( + AttributeData attributeData, + SemanticModel semanticModel) + { + var syntaxReference = attributeData.ApplicationSyntaxReference; + if(syntaxReference?.GetSyntax() is not AttributeSyntax attributeSyntax) + return []; + + var argumentList = attributeSyntax.ArgumentList; + if(argumentList is null) + return []; + + AttributeArgumentSyntax? injectMembersArg = null; + foreach(var arg in argumentList.Arguments) + { + if(arg.NameEquals?.Name.Identifier.Text == "InjectMembers") + { + injectMembersArg = arg; + break; + } + } + + if(injectMembersArg is null) + return []; + + var elements = GetInjectMembersArrayElements(injectMembersArg.Expression); + if(elements is null || elements.Length == 0) + return []; + + List? result = null; + foreach(var elementExpr in elements) + { + var memberData = ParseInjectMemberElement(elementExpr, semanticModel); + if(memberData is not null) + { + result ??= []; + result.Add(memberData); + } + } + + return result?.ToImmutableEquatableArray() ?? []; + } + + private static ExpressionSyntax[]? GetInjectMembersArrayElements(ExpressionSyntax expression) + => expression switch + { + ArrayCreationExpressionSyntax { Initializer: not null } arr + => [.. arr.Initializer.Expressions], + ImplicitArrayCreationExpressionSyntax implicitArr + => [.. implicitArr.Initializer.Expressions], + CollectionExpressionSyntax coll + => [.. coll.Elements.OfType().Select(static e => e.Expression)], + _ => null + }; + + private static InjectionMemberData? ParseInjectMemberElement(ExpressionSyntax expression, SemanticModel semanticModel) + { + // Case 1: nameof(X) — inject without key + if(IsNameofInvocation(expression)) + { + var symbol = ResolveInjectMemberSymbolFromNameof(expression, semanticModel); + return symbol is not null && IsInjectableMember(symbol) ? CreateInjectionMemberFromSymbol(symbol, key: null, semanticModel) : null; + } + + // Case 2: { nameof(X), key [, KeyType] } — keyed injection + var nested = GetInjectMembersArrayElements(expression); + if(nested is null || nested.Length < 2) + return null; + + if(!IsNameofInvocation(nested[0])) + return null; + + // Reject arrays with more than 3 elements + if(nested.Length > 3) + return null; + + var memberSymbol = ResolveInjectMemberSymbolFromNameof(nested[0], semanticModel); + if(memberSymbol is null || !IsInjectableMember(memberSymbol)) + return null; + + // Parse KeyType from optional element [2] (default = 0 = Value) + int keyType = 0; + if(nested.Length > 2) + { + var ktConst = semanticModel.GetConstantValue(nested[2]); + if(ktConst.HasValue && ktConst.Value is int kt && kt is 0 or 1) + keyType = kt; + else + return null; // Invalid KeyType — skip this element + } + + // Parse key value from element [1] + string? key; + if(keyType == 1) // Csharp + { + if(IsNameofInvocation(nested[1])) + { + var inner = ((InvocationExpressionSyntax)nested[1]).ArgumentList.Arguments[0].Expression; + // Resolve to fully qualified path, consistent with TryGetNameof + key = RoslynExtensions.ResolveNameofExpression(inner, semanticModel) + ?? inner.ToString(); + } + else + { + key = nested[1].ToFullString().Trim(); + } + } + else // Value + { + var constVal = semanticModel.GetConstantValue(nested[1]); + if(constVal.HasValue && constVal.Value is not null) + { + var typeInfo = semanticModel.GetTypeInfo(nested[1]); + key = typeInfo.Type is not null + ? RoslynExtensions.FormatPrimitiveConstant(typeInfo.Type, constVal.Value) + : constVal.Value.ToString(); + } + else + { + return null; + } + } + + return CreateInjectionMemberFromSymbol(memberSymbol, key, semanticModel); + } + + /// + /// Checks whether a symbol is considered injectable: non-static, with suitable accessibility, + /// and meeting the specific criteria for properties, fields, or methods. + /// + private static bool IsInjectableMember(ISymbol symbol) + { + if(symbol.IsStatic) + return false; + + return symbol switch + { + IPropertySymbol property => property.SetMethod is not null + && property.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal + && property.SetMethod.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal, + IFieldSymbol field => !field.IsReadOnly + && field.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal, + IMethodSymbol method => method.MethodKind == MethodKind.Ordinary + && method.ReturnsVoid + && !method.IsGenericMethod + && method.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal, + _ => false + }; + } + + private static bool IsNameofInvocation(ExpressionSyntax expression) + => expression is InvocationExpressionSyntax { Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } } invocation + && invocation.ArgumentList.Arguments.Count == 1; + + private static ISymbol? ResolveInjectMemberSymbolFromNameof(ExpressionSyntax nameofExpr, SemanticModel semanticModel) + { + if(nameofExpr is not InvocationExpressionSyntax invocation) + return null; + var inner = invocation.ArgumentList.Arguments[0].Expression; + var info = semanticModel.GetSymbolInfo(inner); + return info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); + } + + private static InjectionMemberData? CreateInjectionMemberFromSymbol(ISymbol symbol, string? key, SemanticModel? semanticModel) + => symbol switch + { + IPropertySymbol property => CreatePropertyInjection(property, key), + IFieldSymbol field => CreateFieldInjection(field, key), + IMethodSymbol method => CreateMethodInjection(method, key, semanticModel), + _ => null + }; + } diff --git a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Models/TransformExtensions.cs b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Models/TransformExtensions.cs index fb396ee..3c57e96 100644 --- a/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Models/TransformExtensions.cs +++ b/src/Ioc/src/SourceGen.Ioc.SourceGenerator/Models/TransformExtensions.cs @@ -1106,8 +1106,58 @@ public ImmutableEquatableArray GetTags() }; } + /// + /// Extracts from the registration attribute's + /// GenericFactoryTypeMapping named property. + /// Used as a fallback when [IocGenericFactory] is not present on the factory method. + /// + /// The generic factory type mapping, or null if not specified or invalid. + public GenericFactoryTypeMapping? ExtractGenericFactoryMappingFromAttributeProperty() + { + foreach(var namedArg in attribute.NamedArguments) + { + if(namedArg.Key != "GenericFactoryTypeMapping") + continue; + + if(namedArg.Value.Kind != TypedConstantKind.Array || namedArg.Value.IsNull) + return null; + + var typeArray = namedArg.Value.Values; + if(typeArray.Length < 2) + return null; + + if(typeArray[0].Value is not INamedTypeSymbol serviceTypeTemplate) + return null; + + var serviceTypeTemplateData = serviceTypeTemplate.GetTypeData(); + + var placeholderMap = new Dictionary(StringComparer.Ordinal); + for(int i = 1; i < typeArray.Length; i++) + { + if(typeArray[i].Value is ITypeSymbol placeholderType) + { + var placeholderTypeName = placeholderType.FullyQualifiedName; + if(placeholderMap.ContainsKey(placeholderTypeName)) + return null; // Duplicate placeholder + placeholderMap[placeholderTypeName] = i - 1; + } + } + + if(placeholderMap.Count != typeArray.Length - 1) + return null; + + return new GenericFactoryTypeMapping( + serviceTypeTemplateData, + placeholderMap.ToImmutableEquatableDictionary()); + } + + return null; + } + /// /// Gets the Factory method data from the attribute, including parameter and return type information. + /// When the resolved factory method is generic but has no [IocGenericFactory] attribute, + /// falls back to the GenericFactoryTypeMapping property on the registration attribute. /// /// Semantic model to resolve method symbols. /// The factory method data, or null if not specified. @@ -1137,7 +1187,17 @@ invocation.Expression is IdentifierNameSyntax identifierName && if(methodSymbol is not null) { - return CreateFactoryMethodData(methodSymbol); + var factoryData = CreateFactoryMethodData(methodSymbol); + + // Fallback: if method is generic but has no [IocGenericFactory], check attribute's GenericFactoryTypeMapping + if(factoryData.GenericTypeMapping is null && methodSymbol.TypeParameters.Length > 0) + { + var mappingFromAttr = attribute.ExtractGenericFactoryMappingFromAttributeProperty(); + if(mappingFromAttr is not null) + factoryData = factoryData with { GenericTypeMapping = mappingFromAttr }; + } + + return factoryData; } // Fallback: get path from nameof expression diff --git a/src/Ioc/src/SourceGen.Ioc/IocRegisterDefaultsAttribute.cs b/src/Ioc/src/SourceGen.Ioc/IocRegisterDefaultsAttribute.cs index f20dcfd..997dcf8 100644 --- a/src/Ioc/src/SourceGen.Ioc/IocRegisterDefaultsAttribute.cs +++ b/src/Ioc/src/SourceGen.Ioc/IocRegisterDefaultsAttribute.cs @@ -38,6 +38,13 @@ public sealed class IocRegisterDefaultsAttribute(Type targetServiceType, Service /// public string[] Tags { get; init; } = []; + /// + /// Gets the generic factory type mapping for the factory method. + /// The first type is the service type template with placeholders, + /// subsequent types are placeholder types mapping to factory method type parameters. + /// + public Type[]? GenericFactoryTypeMapping { get; init; } + /// public string? Factory { get; init; } @@ -78,6 +85,13 @@ public sealed class IocRegisterDefaultsAttribute(ServiceLifetime lifetime) : /// public string[] Tags { get; init; } = []; + /// + /// Gets the generic factory type mapping for the factory method. + /// The first type is the service type template with placeholders, + /// subsequent types are placeholder types mapping to factory method type parameters. + /// + public Type[]? GenericFactoryTypeMapping { get; init; } + /// public string? Factory { get; init; } diff --git a/src/Ioc/src/SourceGen.Ioc/IocRegisterForAttribute.cs b/src/Ioc/src/SourceGen.Ioc/IocRegisterForAttribute.cs index ce383ad..9b70c32 100644 --- a/src/Ioc/src/SourceGen.Ioc/IocRegisterForAttribute.cs +++ b/src/Ioc/src/SourceGen.Ioc/IocRegisterForAttribute.cs @@ -51,9 +51,44 @@ public IocRegisterForAttribute(Type implementationType, ServiceLifetime lifetime /// public string[] Tags { get; init; } = []; + /// + /// Gets the members to inject via dependency injection. + /// Each element is either: + /// + /// nameof(member): inject without key + /// new object[] { nameof(member), key }: inject with keyed service (KeyType = Value) + /// new object[] { nameof(member), key, KeyType.Csharp }: inject with explicit KeyType + /// + /// + public object[]? InjectMembers { get; init; } + /// public string? Factory { get; init; } + /// + /// Gets the generic factory type mapping for the factory method.
+ /// The first type is the service type template with placeholders, + /// subsequent types are placeholder types mapping to factory method type parameters. + ///
+ /// + /// + /// Define: + /// [IocRegisterFor(typeof(IRequestHandler<>), + /// Factory = nameof(FactoryContainer.Create), + /// GenericFactoryTypeMapping = [typeof(IRequestHandler<Task<int>>), typeof(int)])] + /// public class FactoryContainer ↑ ↑ + /// { └--------------------┘ + /// "int" is a placeholder, make sure each placeholder is unique + /// in the context of the generic type mapping. + /// public static IRequestHandler<Task<T>> Create<T>() => new Handler<T>(); + /// } + /// + /// Generate: + /// services.AddSingleton<IRequestHandler<Task<Entity>>>(sp => FactoryContainer.Create<Entity>()); + /// + /// + public Type[]? GenericFactoryTypeMapping { get; init; } + /// public string? Instance { get; init; } } @@ -109,9 +144,15 @@ public IocRegisterForAttribute(ServiceLifetime lifetime) /// public string[] Tags { get; init; } = []; + /// + public object[]? InjectMembers { get; init; } + /// public string? Factory { get; init; } + /// + public Type[]? GenericFactoryTypeMapping { get; init; } + /// public string? Instance { get; init; } } diff --git a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC007Tests.cs b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC007Tests.cs index f59583e..aa85dbf 100644 --- a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC007Tests.cs +++ b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC007Tests.cs @@ -516,4 +516,267 @@ public class TestService : IService await Assert.That(sgioc007).Count().IsEqualTo(1); } + + [Test] + public async Task SGIOC007_InjectAttribute_OnProtectedProperty_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + [IocInject] + protected IService? Dependency { get; protected set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007").ToList(); + + await Assert.That(sgioc007).Count().IsEqualTo(1); + await Assert.That(sgioc007[0].GetMessage()).Contains("Dependency").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnProtectedField_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + [IocInject] + protected IService? _dependency; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007").ToList(); + + await Assert.That(sgioc007).Count().IsEqualTo(1); + await Assert.That(sgioc007[0].GetMessage()).Contains("_dependency").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnProtectedMethod_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + [IocInject] + protected void Initialize(IService service) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007").ToList(); + + await Assert.That(sgioc007).Count().IsEqualTo(1); + await Assert.That(sgioc007[0].GetMessage()).Contains("Initialize").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnPrivateProtectedProperty_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + [IocInject] + private protected IService? Dependency { get; private protected set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007").ToList(); + + await Assert.That(sgioc007).Count().IsEqualTo(1); + await Assert.That(sgioc007[0].GetMessage()).Contains("Dependency").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnPrivateProtectedField_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + [IocInject] + private protected IService? _dependency; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007").ToList(); + + await Assert.That(sgioc007).Count().IsEqualTo(1); + await Assert.That(sgioc007[0].GetMessage()).Contains("_dependency").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnPrivateProtectedMethod_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + [IocInject] + private protected void Initialize(IService service) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007").ToList(); + + await Assert.That(sgioc007).Count().IsEqualTo(1); + await Assert.That(sgioc007[0].GetMessage()).Contains("Initialize").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnConstructor_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + public TestService() { } + + [IocInject] + public TestService(IService service) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007"); + + await Assert.That(sgioc007).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnStaticConstructor_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + [IocInject] + static TestService() { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007").ToList(); + + await Assert.That(sgioc007).Count().IsEqualTo(1); + await Assert.That(sgioc007[0].GetMessage()).Contains("static"); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnPrivateConstructor_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + public TestService() { } + + [IocInject] + private TestService(IService service) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007"); + + await Assert.That(sgioc007).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC007_InjectAttribute_OnProtectedConstructor_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IService { } + + [IocRegister] + public class TestService : IService + { + public TestService() { } + + [IocInject] + protected TestService(IService service) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc007 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC007"); + + await Assert.That(sgioc007).Count().IsEqualTo(0); + } } diff --git a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC016Tests.cs b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC016Tests.cs index ec3cac6..9dca62e 100644 --- a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC016Tests.cs +++ b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC016Tests.cs @@ -192,4 +192,164 @@ public static class FactoryContainer await Assert.That(sgioc016).Count().IsEqualTo(1); await Assert.That(sgioc016[0].GetMessage()).Contains("Create"); } + + [Test] + public async Task SGIOC016_GenericFactory_WithGenericFactoryTypeMappingOnAttribute_NoDiagnostic() + { + const string source = """ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IRequestHandler { } + + [IocRegisterDefaults(typeof(IRequestHandler<>), + ServiceLifetime.Singleton, + Factory = nameof(FactoryContainer.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler>), typeof(int)])] + public static class FactoryContainer + { + // Generic factory without [IocGenericFactory] but GenericFactoryTypeMapping is on the attribute + // SGIOC016 should NOT fire + public static IRequestHandler> Create() => throw new NotImplementedException(); + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc016 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC016"); + + await Assert.That(sgioc016).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC016_GenericFactory_WithGenericFactoryTypeMappingOnIocRegisterFor_NoDiagnostic() + { + const string source = """ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IRequestHandler { } + + public class Handler : IRequestHandler> { } + + [IocRegisterFor( + typeof(Handler<>), + Lifetime = ServiceLifetime.Singleton, + ServiceTypes = [typeof(IRequestHandler<>)], + Factory = nameof(FactoryContainer.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler>), typeof(int)])] + public static class FactoryContainer + { + // Generic factory without [IocGenericFactory] but GenericFactoryTypeMapping on attribute + public static IRequestHandler> Create() => throw new NotImplementedException(); + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc016 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC016"); + + await Assert.That(sgioc016).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC016_GenericFactory_WithDuplicateGenericFactoryTypeMappingOnAttribute_ReportsDiagnostic() + { + const string source = """ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IRequestHandler { } + + [IocRegisterDefaults(typeof(IRequestHandler<>), + ServiceLifetime.Singleton, + Factory = nameof(FactoryContainer.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler>), typeof(int), typeof(int)])] + public static class FactoryContainer + { + // Generic factory with duplicate placeholders in GenericFactoryTypeMapping - SGIOC016 should still fire + public static IRequestHandler> Create() => throw new NotImplementedException(); + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc016 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC016").ToList(); + + await Assert.That(sgioc016).Count().IsEqualTo(1); + await Assert.That(sgioc016[0].GetMessage()).Contains("Create"); + } + + [Test] + public async Task SGIOC016_GenericFactory_WithInsufficientGenericFactoryTypeMapping_ReportsDiagnostic() + { + const string source = """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IRequestHandler { } + + [IocRegisterDefaults(typeof(IRequestHandler<,>), + ServiceLifetime.Singleton, + Factory = nameof(FactoryContainer.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler, List>), typeof(int)])] + public static class FactoryContainer + { + // 2 type params but mapping only provides 1 placeholder - SGIOC016 should fire + public static IRequestHandler, List> Create() => throw new NotImplementedException(); + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc016 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC016").ToList(); + + await Assert.That(sgioc016).Count().IsEqualTo(1); + await Assert.That(sgioc016[0].GetMessage()).Contains("Create"); + } + + [Test] + public async Task SGIOC016_GenericFactory_WithExcessiveGenericFactoryTypeMapping_ReportsDiagnostic() + { + const string source = """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IRequestHandler { } + + [IocRegisterDefaults(typeof(IRequestHandler<>), + ServiceLifetime.Singleton, + Factory = nameof(FactoryContainer.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler>), typeof(int), typeof(string)])] + public static class FactoryContainer + { + // 1 type param but mapping provides 2 placeholders - SGIOC016 should fire + public static IRequestHandler> Create() => throw new NotImplementedException(); + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc016 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC016").ToList(); + + await Assert.That(sgioc016).Count().IsEqualTo(1); + await Assert.That(sgioc016[0].GetMessage()).Contains("Create"); + } } diff --git a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC017Tests.cs b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC017Tests.cs index ca29296..106c253 100644 --- a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC017Tests.cs +++ b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC017Tests.cs @@ -176,4 +176,35 @@ public static class FactoryContainer await Assert.That(sgioc017).Count().IsEqualTo(1); await Assert.That(sgioc017[0].GetMessage()).Contains("List"); } + + [Test] + public async Task SGIOC017_GenericFactoryTypeMapping_WithDuplicatePlaceholders_ReportsDiagnostic() + { + const string source = """ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IRequestHandler { } + + [IocRegisterDefaults(typeof(IRequestHandler<>), + ServiceLifetime.Singleton, + Factory = nameof(FactoryContainer.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler>), typeof(int), typeof(int)])] + public static class FactoryContainer + { + // Duplicate placeholders in GenericFactoryTypeMapping - should report SGIOC017 + public static IRequestHandler> Create() => throw new NotImplementedException(); + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc017 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, "SGIOC017").ToList(); + + await Assert.That(sgioc017).Count().IsEqualTo(1); + await Assert.That(sgioc017[0].GetMessage()).Contains("int").And.Contains("duplicated"); + } } diff --git a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC023Tests.cs b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC023Tests.cs new file mode 100644 index 0000000..81c6a5b --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC023Tests.cs @@ -0,0 +1,256 @@ +namespace SourceGen.Ioc.Test.Analyzer; + +/// +/// Tests for SGIOC023: Invalid InjectMembers element format. +/// +[Category(Constants.Analyzer)] +[Category(Constants.SGIOC023)] +public class SGIOC023Tests +{ + [Test] + public async Task SGIOC023_ValidNameof_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.Dep)])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023); + + await Assert.That(sgioc023).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC023_ValidNameofWithKey_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [new object[] { nameof(MyService.Dep), "myKey" }])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023); + + await Assert.That(sgioc023).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC023_StringLiteralElement_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + // Raw string literal instead of nameof() — invalid format + [IocRegisterFor(typeof(MyService), InjectMembers = ["Dep"])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023).ToList(); + + await Assert.That(sgioc023).Count().IsEqualTo(1); + await Assert.That(sgioc023[0].GetMessage()).Contains("0"); + } + + [Test] + public async Task SGIOC023_ArrayWithMissingFirstNameof_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + // Array where first element is a string literal, not nameof() — invalid format + [IocRegisterFor(typeof(MyService), InjectMembers = [new object[] { "Dep", "myKey" }])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023).ToList(); + + await Assert.That(sgioc023).Count().IsEqualTo(1); + } + + [Test] + public async Task SGIOC023_ArrayWithSingleElement_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + // Array with only one element (needs at least nameof + key) — invalid format + [IocRegisterFor(typeof(MyService), InjectMembers = [new object[] { nameof(MyService.Dep) }])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023).ToList(); + + await Assert.That(sgioc023).Count().IsEqualTo(1); + } + + [Test] + public async Task SGIOC023_MultipleInvalidElements_ReportsMultipleDiagnostics() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + // Two invalid elements + [IocRegisterFor(typeof(MyService), InjectMembers = ["Dep", "Dep2"])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + public IDependency? Dep2 { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023).ToList(); + + await Assert.That(sgioc023).Count().IsEqualTo(2); + } + + [Test] + public async Task SGIOC023_NestedArrayWithTooManyElements_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + // Array with 4 elements (> 3) — invalid format + [IocRegisterFor(typeof(MyService), InjectMembers = [new object[] { nameof(MyService.Dep), "myKey", KeyType.Value, "extra" }])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023).ToList(); + + await Assert.That(sgioc023).Count().IsEqualTo(1); + } + + [Test] + public async Task SGIOC023_NestedArrayWithInvalidThirdElement_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + // Array where 3rd element is a string (not a KeyType constant) — invalid format + [IocRegisterFor(typeof(MyService), InjectMembers = [new object[] { nameof(MyService.Dep), "myKey", "notAKeyType" }])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023).ToList(); + + await Assert.That(sgioc023).Count().IsEqualTo(1); + } + + [Test] + public async Task SGIOC023_ValidNameofWithKeyAndCsharpKeyType_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + public static class Keys + { + public static string PrimaryKey => "primary"; + } + + [IocRegisterFor(typeof(MyService), InjectMembers = [new object[] { nameof(MyService.Dep), nameof(Keys.PrimaryKey), KeyType.Csharp }])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc023 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC023); + + await Assert.That(sgioc023).Count().IsEqualTo(0); + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC024Tests.cs b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC024Tests.cs new file mode 100644 index 0000000..5ad39cb --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/Analyzer/SGIOC024Tests.cs @@ -0,0 +1,426 @@ +namespace SourceGen.Ioc.Test.Analyzer; + +/// +/// Tests for SGIOC024: InjectMembers specifies non-injectable member. +/// +[Category(Constants.Analyzer)] +[Category(Constants.SGIOC024)] +public class SGIOC024Tests +{ + [Test] + public async Task SGIOC024_NonStaticProperty_WithSetter_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.Dep)])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024); + + await Assert.That(sgioc024).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC024_NonStaticField_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.Dep)])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024); + + await Assert.That(sgioc024).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC024_VoidMethod_NoDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.InjectDep)])] + public static class MyModule { } + + public class MyService + { + public void InjectDep(IDependency dep) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024); + + await Assert.That(sgioc024).Count().IsEqualTo(0); + } + + [Test] + public async Task SGIOC024_StaticProperty_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.Dep)])] + public static class MyModule { } + + public class MyService + { + public static IDependency? Dep { get; set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("Dep").And.Contains("static"); + } + + [Test] + public async Task SGIOC024_PropertyWithoutSetter_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.Dep)])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("Dep").And.Contains("setter"); + } + + [Test] + public async Task SGIOC024_PropertyWithPrivateSetter_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.Dep)])] + public static class MyModule { } + + public class MyService + { + public IDependency? Dep { get; private set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("Dep").And.Contains("private"); + } + + [Test] + public async Task SGIOC024_ReadonlyField_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.Dep)])] + public static class MyModule { } + + public class MyService + { + public readonly IDependency? Dep; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("Dep").And.Contains("readonly"); + } + + [Test] + public async Task SGIOC024_PrivateField_ReportsDiagnostic() + { + // When [IocRegisterFor] is on the class itself, nameof can resolve private members + // but the generator cannot set them — SGIOC024 should fire + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(_dep)])] + public class MyService + { + private IDependency? _dep; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("_dep").And.Contains("private"); + } + + [Test] + public async Task SGIOC024_NonVoidMethod_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.GetDep)])] + public static class MyModule { } + + public class MyService + { + public IDependency? GetDep(IDependency dep) => dep; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("GetDep").And.Contains("void"); + } + + [Test] + public async Task SGIOC024_GenericMethod_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(MyService.InjectDep)])] + public static class MyModule { } + + public class MyService + { + public void InjectDep(T dep) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("InjectDep").And.Contains("generic"); + } + + [Test] + public async Task SGIOC024_ProtectedProperty_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(Dep)])] + public class MyService + { + protected IDependency? Dep { get; protected set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("Dep").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC024_ProtectedField_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(Dep)])] + public class MyService + { + protected IDependency? Dep; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("Dep").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC024_PrivateProtectedField_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(_dep)])] + public class MyService + { + private protected IDependency? _dep; + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("_dep").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC024_ProtectedMethod_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(InjectDep)])] + public class MyService + { + protected void InjectDep(IDependency dep) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("InjectDep").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC024_PrivateProtectedMethod_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(InjectDep)])] + public class MyService + { + private protected void InjectDep(IDependency dep) { } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("InjectDep").And.Contains("not accessible"); + } + + [Test] + public async Task SGIOC024_PrivateProtectedProperty_ReportsDiagnostic() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegisterFor(typeof(MyService), InjectMembers = [nameof(Dep)])] + public class MyService + { + private protected IDependency? Dep { get; private protected set; } + } + """; + + var diagnostics = await SourceGeneratorTestHelper.RunAnalyzerAsync(source); + var sgioc024 = SourceGeneratorTestHelper.GetDiagnosticsById(diagnostics, Constants.SGIOC024).ToList(); + + await Assert.That(sgioc024).Count().IsEqualTo(1); + await Assert.That(sgioc024[0].GetMessage()).Contains("Dep").And.Contains("not accessible"); + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/Helpers/Constants.cs b/src/Ioc/test/SourceGen.Ioc.Test/Helpers/Constants.cs index d539a67..4b8f9a3 100644 --- a/src/Ioc/test/SourceGen.Ioc.Test/Helpers/Constants.cs +++ b/src/Ioc/test/SourceGen.Ioc.Test/Helpers/Constants.cs @@ -10,6 +10,7 @@ internal static class Constants public const string Tags = "Tags"; public const string Decorator = "Decorator"; public const string InjectAttribute = "InjectAttribute"; + public const string InjectMembers = "InjectMembers"; public const string ImportModule = "ImportModule"; public const string SpecialParameter = "SpecialParameter"; public const string Collection = "Collection"; @@ -48,4 +49,6 @@ internal static class Constants public const string SGIOC020 = "SGIOC020"; public const string SGIOC021 = "SGIOC021"; public const string SGIOC022 = "SGIOC022"; + public const string SGIOC023 = "SGIOC023"; + public const string SGIOC024 = "SGIOC024"; } diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/FactoryAndInstanceTests.GenericFactory_TypeMappingOnAttribute_SingleTypeParameter_GeneratesGenericFactoryCall.verified.txt b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/FactoryAndInstanceTests.GenericFactory_TypeMappingOnAttribute_SingleTypeParameter_GeneratesGenericFactoryCall.verified.txt new file mode 100644 index 0000000..a78a86e --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/FactoryAndInstanceTests.GenericFactory_TypeMappingOnAttribute_SingleTypeParameter_GeneratesGenericFactoryCall.verified.txt @@ -0,0 +1,32 @@ +// +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; + +namespace TestAssembly +{ + /// + /// Extension methods for registering services from TestAssembly. + /// + public static class TestAssemblyServiceCollectionExtensions + { + /// + /// Registers services. Services with tags are only registered when matching tags are passed. + /// + /// The service collection. + /// Optional tags to filter which services to register. + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestAssembly(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, params global::System.Collections.Generic.IEnumerable tags) + { + if (!tags.Any()) + { + services.AddSingleton(typeof(global::TestNamespace.Handler<>), typeof(global::TestNamespace.Handler<>)); + services.AddSingleton, global::TestNamespace.Handler>(); + services.AddSingleton>>((global::System.IServiceProvider sp) => (global::TestNamespace.IRequestHandler>)global::TestNamespace.FactoryContainer.Create()); + } + + return services; + } + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/FactoryAndInstanceTests.cs b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/FactoryAndInstanceTests.cs index 36a794d..2df2848 100644 --- a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/FactoryAndInstanceTests.cs +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/FactoryAndInstanceTests.cs @@ -1012,4 +1012,43 @@ public sealed class App { } await Verify(generatedSource); } + + [Test] + public async Task GenericFactory_TypeMappingOnAttribute_SingleTypeParameter_GeneratesGenericFactoryCall() + { + const string source = """ + using System; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IRequestHandler { } + + [IocRegisterDefaults(typeof(IRequestHandler<>), + ServiceLifetime.Singleton, + Factory = nameof(FactoryContainer.Create), + GenericFactoryTypeMapping = [typeof(IRequestHandler>), typeof(int)])] + public static class FactoryContainer + { + // Generic factory without [IocGenericFactory]; mapping provided on attribute + public static IRequestHandler> Create() => throw new NotImplementedException(); + } + + [IocRegister(ServiceTypes = [typeof(IRequestHandler<>)])] + public class Handler : IRequestHandler> { } + + public class Entity { } + + [IocDiscover(typeof(IRequestHandler>))] + public sealed class App { } + """; + + var result = SourceGeneratorTestHelper.RunGenerator(source); + await result.VerifyCompilableAsync(); + var generatedSource = SourceGeneratorTestHelper.GetGeneratedSource(result, "ServiceRegistration"); + + await Verify(generatedSource); + } } diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_And_IocInject_Mixed_IocInjectTakesPriority.verified.txt b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_And_IocInject_Mixed_IocInjectTakesPriority.verified.txt new file mode 100644 index 0000000..6d061e9 --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_And_IocInject_Mixed_IocInjectTakesPriority.verified.txt @@ -0,0 +1,37 @@ +// +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; + +namespace TestAssembly +{ + /// + /// Extension methods for registering services from TestAssembly. + /// + public static class TestAssemblyServiceCollectionExtensions + { + /// + /// Registers services. Services with tags are only registered when matching tags are passed. + /// + /// The service collection. + /// Optional tags to filter which services to register. + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestAssembly(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, params global::System.Collections.Generic.IEnumerable tags) + { + if (!tags.Any()) + { + services.AddSingleton(); + services.AddKeyedSingleton("keyed"); + services.AddTransient((global::System.IServiceProvider sp) => + { + var s0_p0 = sp.GetKeyedService("keyed"); + var s0 = new global::TestNamespace.MyService() { Dep = s0_p0 }; + return s0; + }); + } + + return services; + } + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_FieldInjection_NoKey_GeneratesFactoryMethod.verified.txt b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_FieldInjection_NoKey_GeneratesFactoryMethod.verified.txt new file mode 100644 index 0000000..67f9725 --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_FieldInjection_NoKey_GeneratesFactoryMethod.verified.txt @@ -0,0 +1,36 @@ +// +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; + +namespace TestAssembly +{ + /// + /// Extension methods for registering services from TestAssembly. + /// + public static class TestAssemblyServiceCollectionExtensions + { + /// + /// Registers services. Services with tags are only registered when matching tags are passed. + /// + /// The service collection. + /// Optional tags to filter which services to register. + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestAssembly(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, params global::System.Collections.Generic.IEnumerable tags) + { + if (!tags.Any()) + { + services.AddSingleton(); + services.AddTransient((global::System.IServiceProvider sp) => + { + var s0_p0 = sp.GetService(); + var s0 = new global::TestNamespace.MyService() { _dep = s0_p0 }; + return s0; + }); + } + + return services; + } + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_MultipleMembers_GeneratesFactoryMethod.verified.txt b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_MultipleMembers_GeneratesFactoryMethod.verified.txt new file mode 100644 index 0000000..6f7f0eb --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_MultipleMembers_GeneratesFactoryMethod.verified.txt @@ -0,0 +1,38 @@ +// +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; + +namespace TestAssembly +{ + /// + /// Extension methods for registering services from TestAssembly. + /// + public static class TestAssemblyServiceCollectionExtensions + { + /// + /// Registers services. Services with tags are only registered when matching tags are passed. + /// + /// The service collection. + /// Optional tags to filter which services to register. + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestAssembly(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, params global::System.Collections.Generic.IEnumerable tags) + { + if (!tags.Any()) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient((global::System.IServiceProvider sp) => + { + var s0_p0 = sp.GetService(); + var s0_p1 = sp.GetService(); + var s0 = new global::TestNamespace.MyService() { Dep1 = s0_p0, Dep2 = s0_p1 }; + return s0; + }); + } + + return services; + } + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_PropertyInjection_NoKey_GeneratesFactoryMethod.verified.txt b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_PropertyInjection_NoKey_GeneratesFactoryMethod.verified.txt new file mode 100644 index 0000000..ec45be1 --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_PropertyInjection_NoKey_GeneratesFactoryMethod.verified.txt @@ -0,0 +1,36 @@ +// +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; + +namespace TestAssembly +{ + /// + /// Extension methods for registering services from TestAssembly. + /// + public static class TestAssemblyServiceCollectionExtensions + { + /// + /// Registers services. Services with tags are only registered when matching tags are passed. + /// + /// The service collection. + /// Optional tags to filter which services to register. + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestAssembly(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, params global::System.Collections.Generic.IEnumerable tags) + { + if (!tags.Any()) + { + services.AddSingleton(); + services.AddTransient((global::System.IServiceProvider sp) => + { + var s0_p0 = sp.GetService(); + var s0 = new global::TestNamespace.MyService() { Dep = s0_p0 }; + return s0; + }); + } + + return services; + } + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_WithCsharpKeyType_GeneratesKeyedInjection.verified.txt b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_WithCsharpKeyType_GeneratesKeyedInjection.verified.txt new file mode 100644 index 0000000..d52b254 --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_WithCsharpKeyType_GeneratesKeyedInjection.verified.txt @@ -0,0 +1,36 @@ +// +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; + +namespace TestAssembly +{ + /// + /// Extension methods for registering services from TestAssembly. + /// + public static class TestAssemblyServiceCollectionExtensions + { + /// + /// Registers services. Services with tags are only registered when matching tags are passed. + /// + /// The service collection. + /// Optional tags to filter which services to register. + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestAssembly(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, params global::System.Collections.Generic.IEnumerable tags) + { + if (!tags.Any()) + { + services.AddKeyedSingleton(global::TestNamespace.Keys.PrimaryKey); + services.AddTransient((global::System.IServiceProvider sp) => + { + var s0_p0 = sp.GetKeyedService(global::TestNamespace.Keys.PrimaryKey); + var s0 = new global::TestNamespace.MyService() { Dep = s0_p0 }; + return s0; + }); + } + + return services; + } + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_WithStringKey_GeneratesKeyedInjection.verified.txt b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_WithStringKey_GeneratesKeyedInjection.verified.txt new file mode 100644 index 0000000..e7d7932 --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.InjectMembers_WithStringKey_GeneratesKeyedInjection.verified.txt @@ -0,0 +1,36 @@ +// +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; + +namespace TestAssembly +{ + /// + /// Extension methods for registering services from TestAssembly. + /// + public static class TestAssemblyServiceCollectionExtensions + { + /// + /// Registers services. Services with tags are only registered when matching tags are passed. + /// + /// The service collection. + /// Optional tags to filter which services to register. + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestAssembly(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, params global::System.Collections.Generic.IEnumerable tags) + { + if (!tags.Any()) + { + services.AddKeyedSingleton("myKey"); + services.AddTransient((global::System.IServiceProvider sp) => + { + var s0_p0 = sp.GetKeyedService("myKey"); + var s0 = new global::TestNamespace.MyService() { Dep = s0_p0 }; + return s0; + }); + } + + return services; + } + } +} diff --git a/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.cs b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.cs new file mode 100644 index 0000000..086389c --- /dev/null +++ b/src/Ioc/test/SourceGen.Ioc.Test/RegisterSourceGeneratorSnapshot/InjectMembersTests.cs @@ -0,0 +1,210 @@ +namespace SourceGen.Ioc.Test.RegisterSourceGeneratorSnapshot; + +/// +/// Snapshot tests for the InjectMembers property on registration attributes, +/// which allows specifying property/field/method injection without placing [IocInject] +/// directly on the member. +/// +[Category(Constants.SourceGeneratorSnapshot)] +[Category(Constants.InjectMembers)] +public class InjectMembersTests +{ + [Test] + public async Task InjectMembers_PropertyInjection_NoKey_GeneratesFactoryMethod() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegister(Lifetime = ServiceLifetime.Singleton)] + public class Dependency : IDependency { } + + [IocRegisterFor(typeof(MyService), + InjectMembers = [nameof(MyService.Dep)])] + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var result = SourceGeneratorTestHelper.RunGenerator(source); + await result.VerifyCompilableAsync(); + var generatedSource = SourceGeneratorTestHelper.GetGeneratedSource(result, "ServiceRegistration"); + + await Verify(generatedSource); + } + + [Test] + public async Task InjectMembers_FieldInjection_NoKey_GeneratesFactoryMethod() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegister(Lifetime = ServiceLifetime.Singleton)] + public class Dependency : IDependency { } + + [IocRegisterFor(typeof(MyService), + InjectMembers = [nameof(MyService._dep)])] + public class MyService + { + public IDependency? _dep; + } + """; + + var analyzerConfigOptions = new Dictionary + { + ["build_property.SourceGenIocFeatures"] = "Register,Container,PropertyInject,FieldInject,MethodInject" + }; + + var result = SourceGeneratorTestHelper.RunGenerator( + source, + analyzerConfigOptions: analyzerConfigOptions); + await result.VerifyCompilableAsync(); + var generatedSource = SourceGeneratorTestHelper.GetGeneratedSource(result, "ServiceRegistration"); + + await Verify(generatedSource); + } + + [Test] + public async Task InjectMembers_WithStringKey_GeneratesKeyedInjection() + { + const string source = """ + using System; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegister(Lifetime = ServiceLifetime.Singleton, Key = "myKey")] + public class Dependency : IDependency { } + + [IocRegisterFor(typeof(MyService), + InjectMembers = [new object[] { nameof(MyService.Dep), "myKey" }])] + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var result = SourceGeneratorTestHelper.RunGenerator(source); + await result.VerifyCompilableAsync(); + var generatedSource = SourceGeneratorTestHelper.GetGeneratedSource(result, "ServiceRegistration"); + + await Verify(generatedSource); + } + + [Test] + public async Task InjectMembers_And_IocInject_Mixed_IocInjectTakesPriority() + { + const string source = """ + using System; + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + [IocRegister(Lifetime = ServiceLifetime.Singleton)] + public class Dependency : IDependency { } + + [IocRegister(Lifetime = ServiceLifetime.Singleton, Key = "keyed")] + public class KeyedDependency : IDependency { } + + // InjectMembers specifies Dep with no key, + // but [IocInject] on Dep specifies "keyed" key — [IocInject] wins. + [IocRegisterFor(typeof(MyService), + InjectMembers = [nameof(MyService.Dep)])] + public class MyService + { + [IocInject(Key = "keyed")] + public IDependency? Dep { get; set; } + } + """; + + var result = SourceGeneratorTestHelper.RunGenerator(source); + await result.VerifyCompilableAsync(); + var generatedSource = SourceGeneratorTestHelper.GetGeneratedSource(result, "ServiceRegistration"); + + await Verify(generatedSource); + } + + [Test] + public async Task InjectMembers_MultipleMembers_GeneratesFactoryMethod() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDep1 { } + public interface IDep2 { } + + [IocRegister(Lifetime = ServiceLifetime.Singleton)] + public class Dep1 : IDep1 { } + + [IocRegister(Lifetime = ServiceLifetime.Singleton)] + public class Dep2 : IDep2 { } + + [IocRegisterFor(typeof(MyService), + InjectMembers = [nameof(MyService.Dep1), nameof(MyService.Dep2)])] + public class MyService + { + public IDep1? Dep1 { get; set; } + public IDep2? Dep2 { get; set; } + } + """; + + var result = SourceGeneratorTestHelper.RunGenerator(source); + await result.VerifyCompilableAsync(); + var generatedSource = SourceGeneratorTestHelper.GetGeneratedSource(result, "ServiceRegistration"); + + await Verify(generatedSource); + } + + [Test] + public async Task InjectMembers_WithCsharpKeyType_GeneratesKeyedInjection() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using SourceGen.Ioc; + + namespace TestNamespace; + + public interface IDependency { } + + public static class Keys + { + public static string PrimaryKey => "primary"; + } + + [IocRegister(Lifetime = ServiceLifetime.Singleton, Key = nameof(Keys.PrimaryKey), KeyType = KeyType.Csharp)] + public class Dependency : IDependency { } + + [IocRegisterFor(typeof(MyService), + InjectMembers = [new object[] { nameof(MyService.Dep), nameof(Keys.PrimaryKey), KeyType.Csharp }])] + public class MyService + { + public IDependency? Dep { get; set; } + } + """; + + var result = SourceGeneratorTestHelper.RunGenerator(source); + await result.VerifyCompilableAsync(); + var generatedSource = SourceGeneratorTestHelper.GetGeneratedSource(result, "ServiceRegistration"); + + await Verify(generatedSource); + } +}