diff --git a/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs b/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs index b9ca0e2..d1bef90 100644 --- a/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs +++ b/DesignPatterns.Analyzers/CaptiveDependencyAnalyzer.cs @@ -57,13 +57,6 @@ private sealed class RegistrationEntry public INamedTypeSymbol ImplementationType { get; set; } = null!; public Lifetime Lifetime { get; set; } public InvocationExpressionSyntax Invocation { get; set; } = null!; - - /// - /// True for factory-delegate and instance registrations: the container - /// never invokes the implementation constructor, so constructor analysis - /// does not apply (the type still participates in the lifetime map). - /// - public bool SkipConstructorAnalysis { get; set; } } /// @@ -81,7 +74,8 @@ private sealed class FactoryDelegateEntry private static void OnCompilationStart(CompilationStartAnalysisContext context) { - var registrations = new List(); + var mapBuilder = new DiRegistrationMapBuilder(); + var registerDiEntries = new List(); var factoryDelegates = new List(); // Phase 2: Pre-scan all types for DesignPatterns registration attributes. @@ -90,11 +84,16 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) var attributedTypesByCategory = CollectAttributedImplementationTypes(context.Compilation); context.RegisterSyntaxNodeAction( - syntaxContext => CollectRegistration(syntaxContext, registrations, factoryDelegates, attributedTypesByCategory), + syntaxContext => CollectRegistration( + syntaxContext, + mapBuilder, + registerDiEntries, + factoryDelegates, + attributedTypesByCategory), SyntaxKind.InvocationExpression); context.RegisterCompilationEndAction( - endContext => AnalyzeRegistrations(endContext, registrations, factoryDelegates)); + endContext => AnalyzeRegistrations(endContext, mapBuilder, registerDiEntries, factoryDelegates)); } /// @@ -154,7 +153,8 @@ private static Dictionary> Colle private static void CollectRegistration( SyntaxNodeAnalysisContext context, - List registrations, + DiRegistrationMapBuilder mapBuilder, + List registerDiEntries, List factoryDelegates, Dictionary> attributedTypesByCategory) { @@ -167,42 +167,34 @@ private static void CollectRegistration( var methodName = memberAccess.Name.Identifier.ValueText; - if (methodName is "AddSingleton" or "AddScoped" or "AddTransient") - { - CollectDirectRegistration(context, invocation, methodName, registrations, factoryDelegates); - } - else if (methodName == "TryAdd") + if (methodName is "AddSingleton" or "AddScoped" or "AddTransient" or "TryAdd" or "RegisterType" or "Register") { - CollectTryAddRegistration(context, invocation, registrations); + if (mapBuilder.TryCollect(invocation, context.SemanticModel)) + { + TryCollectFactoryDelegate(context, invocation, methodName, factoryDelegates); + } } else if (methodName == "RegisterDi") { - CollectRegisterDiRegistration(context, invocation, attributedTypesByCategory, registrations); - } - else if (methodName == "RegisterType") - { - CollectAutofacRegisterType(context, invocation, registrations); - } - else if (methodName == "Register") - { - CollectAutofacRegisterDelegate(context, invocation, registrations, factoryDelegates); + CollectRegisterDiRegistration(context, invocation, attributedTypesByCategory, registerDiEntries); } } - private static void CollectDirectRegistration( + /// + /// Collects Singleton factory delegates for DP066. Lifetime map entries for + /// these registrations are owned by . + /// + private static void TryCollectFactoryDelegate( SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, string methodName, - List registrations, List factoryDelegates) { - var lifetime = methodName switch + var args = invocation.ArgumentList?.Arguments ?? default; + if (args.Count == 0 || args[0].Expression is not AnonymousFunctionExpressionSyntax lambda) { - "AddSingleton" => Lifetime.Singleton, - "AddScoped" => Lifetime.Scoped, - "AddTransient" => Lifetime.Transient, - _ => Lifetime.Transient, - }; + return; + } var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; if (methodSymbol is null) @@ -210,160 +202,38 @@ private static void CollectDirectRegistration( return; } - INamedTypeSymbol? implType = null; - var args = invocation.ArgumentList?.Arguments ?? new SeparatedSyntaxList(); - - // AddSingleton() — 2 type args, TImpl is implementation - if (methodSymbol.TypeArguments.Length == 2 && - methodSymbol.TypeArguments[1] is INamedTypeSymbol implFromGeneric) + if (methodName == "AddSingleton") { - implType = implFromGeneric; - } - // AddSingleton() — 1 type arg, TImpl is implementation - else if (methodSymbol.TypeArguments.Length == 1 && - methodSymbol.TypeArguments[0] is INamedTypeSymbol singleGeneric) - { - // Factory/instance registration: the container never calls the - // constructor, so skip constructor analysis but keep the type in - // the lifetime map. Singleton factory lambdas are analyzed for DP066. - if (args.Count > 0 && IsFactoryOrInstanceArg(args[0], context.SemanticModel)) + if (methodSymbol.TypeArguments.Length != 1 || + methodSymbol.TypeArguments[0] is not INamedTypeSymbol serviceType || + AutofacRegistration.IsOpenGeneric(serviceType)) { - if (IsOpenGeneric(singleGeneric)) - { - return; - } - - registrations.Add(new RegistrationEntry - { - ImplementationType = singleGeneric, - Lifetime = lifetime, - Invocation = invocation, - SkipConstructorAnalysis = true, - }); - - if (lifetime == Lifetime.Singleton && - args[0].Expression is AnonymousFunctionExpressionSyntax lambda) - { - factoryDelegates.Add(new FactoryDelegateEntry - { - ServiceType = singleGeneric, - Lambda = lambda, - SemanticModel = context.SemanticModel, - }); - } - return; } - implType = singleGeneric; - } - - if (implType is null) - { - return; - } - - if (IsOpenGeneric(implType)) - { - return; - } - - registrations.Add(new RegistrationEntry - { - ImplementationType = implType, - Lifetime = lifetime, - Invocation = invocation, - }); - } - - /// - /// Collects Autofac RegisterType<TImpl>() / RegisterType(typeof(TImpl)) - /// registrations. Autofac methods are matched by containing namespace - /// (no Autofac package reference). The lifetime is resolved from the - /// fluent chain (SingleInstance etc.); Autofac defaults to - /// InstancePerDependency (Transient). - /// - private static void CollectAutofacRegisterType( - SyntaxNodeAnalysisContext context, - InvocationExpressionSyntax invocation, - List registrations) - { - var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; - if (!IsAutofacMethod(methodSymbol)) - { + factoryDelegates.Add(new FactoryDelegateEntry + { + ServiceType = serviceType, + Lambda = lambda, + SemanticModel = context.SemanticModel, + }); return; } - INamedTypeSymbol? implType = null; - if (methodSymbol!.TypeArguments.Length == 1 && - methodSymbol.TypeArguments[0] is INamedTypeSymbol generic) - { - implType = generic; - } - else + if (methodName == "Register" && AutofacRegistration.IsAutofacMethod(methodSymbol)) { - var args = invocation.ArgumentList?.Arguments ?? new SeparatedSyntaxList(); - if (args.Count == 1) + if (methodSymbol.TypeArguments.Length != 1 || + methodSymbol.TypeArguments[0] is not INamedTypeSymbol serviceType || + AutofacRegistration.IsOpenGeneric(serviceType)) { - implType = ResolveTypeofArg(args[0].Expression, context.SemanticModel); + return; } - } - - if (implType is null || IsOpenGeneric(implType)) - { - return; - } - - registrations.Add(new RegistrationEntry - { - ImplementationType = implType, - Lifetime = ResolveAutofacChainLifetime(invocation), - Invocation = invocation, - }); - } - - /// - /// Collects Autofac Register(c => ...) delegate registrations. - /// The container never calls the implementation constructor, so the entry - /// only feeds the lifetime map; Singleton delegates are analyzed for DP066. - /// - private static void CollectAutofacRegisterDelegate( - SyntaxNodeAnalysisContext context, - InvocationExpressionSyntax invocation, - List registrations, - List factoryDelegates) - { - var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; - if (!IsAutofacMethod(methodSymbol)) - { - return; - } - - if (methodSymbol!.TypeArguments.Length != 1 || - methodSymbol.TypeArguments[0] is not INamedTypeSymbol serviceType || - IsOpenGeneric(serviceType)) - { - return; - } - - var args = invocation.ArgumentList?.Arguments ?? new SeparatedSyntaxList(); - if (args.Count == 0 || args[0].Expression is not AnonymousFunctionExpressionSyntax lambda) - { - return; - } - var lifetime = ResolveAutofacChainLifetime(invocation); - - registrations.Add(new RegistrationEntry - { - ImplementationType = serviceType, - Lifetime = lifetime, - Invocation = invocation, - SkipConstructorAnalysis = true, - }); + if (AutofacRegistration.ResolveChainLifetime(invocation) != Lifetime.Singleton) + { + return; + } - if (lifetime == Lifetime.Singleton) - { factoryDelegates.Add(new FactoryDelegateEntry { ServiceType = serviceType, @@ -373,98 +243,6 @@ methodSymbol.TypeArguments[0] is not INamedTypeSymbol serviceType || } } - private static bool IsAutofacMethod(IMethodSymbol? methodSymbol) => - methodSymbol?.ContainingNamespace?.ToDisplayString() - .StartsWith("Autofac", StringComparison.Ordinal) == true; - - /// - /// Walks up the fluent chain from an Autofac registration call and returns - /// the declared lifetime. Intermediate calls (e.g. As<T>()) - /// are skipped; without an explicit lifetime call Autofac defaults to - /// InstancePerDependency (Transient). - /// - private static Lifetime ResolveAutofacChainLifetime(InvocationExpressionSyntax registrationCall) - { - var lifetime = Lifetime.Transient; - SyntaxNode node = registrationCall; - - while (node.Parent is MemberAccessExpressionSyntax memberAccess && - memberAccess.Parent is InvocationExpressionSyntax parentInvocation) - { - switch (memberAccess.Name.Identifier.ValueText) - { - case "SingleInstance": - lifetime = Lifetime.Singleton; - break; - case "InstancePerLifetimeScope": - case "InstancePerMatchingLifetimeScope": - case "InstancePerRequest": - case "InstancePerOwned": - lifetime = Lifetime.Scoped; - break; - case "InstancePerDependency": - lifetime = Lifetime.Transient; - break; - } - - node = parentInvocation; - } - - return lifetime; - } - - private static bool IsOpenGeneric(INamedTypeSymbol type) => - type.IsUnboundGenericType || - (type.TypeParameters.Length > 0 && type.IsDefinition); - - private static void CollectTryAddRegistration( - SyntaxNodeAnalysisContext context, - InvocationExpressionSyntax invocation, - List registrations) - { - var argList = invocation.ArgumentList; - if (argList is null || argList.Arguments.Count == 0) - { - return; - } - - // TryAdd(new ServiceDescriptor(typeof(TService), typeof(TImpl), ServiceLifetime.Singleton)) - var firstArg = argList.Arguments[0].Expression; - if (firstArg is not ObjectCreationExpressionSyntax objectCreation) - { - return; - } - - var descriptorArgList = objectCreation.ArgumentList; - if (descriptorArgList is null || descriptorArgList.Arguments.Count < 3) - { - return; - } - - var descriptorArgs = descriptorArgList.Arguments; - - // Second arg: typeof(TImpl) - var implType = ResolveTypeofArg(descriptorArgs[1].Expression, context.SemanticModel); - if (implType is null) - { - return; - } - - // Third arg: lifetime constant (e.g. Singleton) - var lifetime = LifetimeResolution.TryResolve(descriptorArgs[2].Expression, context.SemanticModel); - if (lifetime is null) - { - return; - } - - registrations.Add(new RegistrationEntry - { - ImplementationType = implType, - Lifetime = lifetime.Value, - Invocation = invocation, - }); - } - /// /// Collects registrations from generated RegisterDi calls. /// Extracts the implementation lifetime and applies it to the types @@ -585,25 +363,34 @@ private static void CollectRegisterDiRegistration( private static void AnalyzeRegistrations( CompilationAnalysisContext context, - List registrations, + DiRegistrationMapBuilder mapBuilder, + List registerDiEntries, List factoryDelegates) { - if (registrations.Count == 0) + var map = mapBuilder.Build(); + if (map.Entries.Count == 0 && registerDiEntries.Count == 0) { return; } // Build the registration map: type → lifetime (last wins). + // Explicit container registrations come from DiRegistrationMap; + // attributed RegisterDi entries are overlaid (still private until #234). var lifetimeMap = new Dictionary( SymbolEqualityComparer.Default); - foreach (var reg in registrations) + foreach (var pair in map.Lifetimes) + { + lifetimeMap[pair.Key] = pair.Value; + } + + foreach (var reg in registerDiEntries) { lifetimeMap[reg.ImplementationType] = reg.Lifetime; } - // For each Singleton, check constructor parameters. - foreach (var reg in registrations) + // DP062: Singleton constructor analysis for explicit registrations from the map. + foreach (var reg in map.Entries) { if (reg.Lifetime != Lifetime.Singleton || reg.SkipConstructorAnalysis) { @@ -613,6 +400,17 @@ private static void AnalyzeRegistrations( AnalyzeSingleton(context, reg.ImplementationType, reg.Invocation, lifetimeMap); } + // DP062: RegisterDi path still uses the private entry list. + foreach (var reg in registerDiEntries) + { + if (reg.Lifetime != Lifetime.Singleton) + { + continue; + } + + AnalyzeSingleton(context, reg.ImplementationType, reg.Invocation, lifetimeMap); + } + // For each Singleton factory delegate, check resolved services (DP066). foreach (var factory in factoryDelegates) { @@ -726,46 +524,4 @@ private static void AnalyzeSingleton( } } } - - private static INamedTypeSymbol? ResolveTypeofArg( - ExpressionSyntax expr, - SemanticModel semanticModel) - { - if (expr is not TypeOfExpressionSyntax typeofExpr) - { - return null; - } - - var typeInfo = semanticModel.GetTypeInfo(typeofExpr.Type); - return typeInfo.Type as INamedTypeSymbol; - } - - private static bool IsFactoryOrInstanceArg( - ArgumentSyntax arg, - SemanticModel semanticModel) - { - var expr = arg.Expression; - - // Lambda → factory - if (expr is SimpleLambdaExpressionSyntax or ParenthesizedLambdaExpressionSyntax) - { - return true; - } - - // Check if it's a delegate type (Func) - var typeInfo = semanticModel.GetTypeInfo(expr); - if (typeInfo.Type is not null && - typeInfo.Type.TypeKind == TypeKind.Delegate) - { - return true; - } - - // Object creation, method invocation → likely instance - if (expr is ObjectCreationExpressionSyntax or InvocationExpressionSyntax) - { - return true; - } - - return false; - } } diff --git a/DesignPatterns.Analyzers/Di/AutofacRegistration.cs b/DesignPatterns.Analyzers/Di/AutofacRegistration.cs new file mode 100644 index 0000000..436668e --- /dev/null +++ b/DesignPatterns.Analyzers/Di/AutofacRegistration.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DesignPatterns.Analyzers.Di; + +/// +/// Helpers for Autofac registration calls matched by namespace (no Autofac package reference). +/// +internal static class AutofacRegistration +{ + internal static bool IsAutofacMethod(IMethodSymbol? methodSymbol) => + methodSymbol?.ContainingNamespace?.ToDisplayString() + .StartsWith("Autofac", StringComparison.Ordinal) == true; + + /// + /// Walks up the fluent chain from an Autofac registration call and returns + /// the declared lifetime. Intermediate calls (e.g. As<T>()) + /// are skipped; without an explicit lifetime call Autofac defaults to + /// InstancePerDependency (Transient). + /// + internal static Lifetime ResolveChainLifetime(InvocationExpressionSyntax registrationCall) + { + var lifetime = Lifetime.Transient; + SyntaxNode node = registrationCall; + + while (node.Parent is MemberAccessExpressionSyntax memberAccess && + memberAccess.Parent is InvocationExpressionSyntax parentInvocation) + { + switch (memberAccess.Name.Identifier.ValueText) + { + case "SingleInstance": + lifetime = Lifetime.Singleton; + break; + case "InstancePerLifetimeScope": + case "InstancePerMatchingLifetimeScope": + case "InstancePerRequest": + case "InstancePerOwned": + lifetime = Lifetime.Scoped; + break; + case "InstancePerDependency": + lifetime = Lifetime.Transient; + break; + } + + node = parentInvocation; + } + + return lifetime; + } + + internal static bool IsOpenGeneric(INamedTypeSymbol type) => + type.IsUnboundGenericType || + (type.TypeParameters.Length > 0 && type.IsDefinition); +} diff --git a/DesignPatterns.Analyzers/Di/DiRegistration.cs b/DesignPatterns.Analyzers/Di/DiRegistration.cs new file mode 100644 index 0000000..36366e3 --- /dev/null +++ b/DesignPatterns.Analyzers/Di/DiRegistration.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DesignPatterns.Analyzers.Di; + +/// +/// A single explicit container registration contributing to the registration map. +/// +internal sealed class DiRegistration +{ + public DiRegistration( + INamedTypeSymbol implementationType, + Lifetime lifetime, + InvocationExpressionSyntax invocation, + bool skipConstructorAnalysis = false) + { + ImplementationType = implementationType; + Lifetime = lifetime; + Invocation = invocation; + SkipConstructorAnalysis = skipConstructorAnalysis; + } + + public INamedTypeSymbol ImplementationType { get; } + + public Lifetime Lifetime { get; } + + public InvocationExpressionSyntax Invocation { get; } + + /// + /// True for factory-delegate and instance registrations: the container + /// never invokes the implementation constructor, so constructor analysis + /// does not apply (the type still participates in the lifetime map). + /// + public bool SkipConstructorAnalysis { get; } +} diff --git a/DesignPatterns.Analyzers/Di/DiRegistrationMap.cs b/DesignPatterns.Analyzers/Di/DiRegistrationMap.cs new file mode 100644 index 0000000..f213542 --- /dev/null +++ b/DesignPatterns.Analyzers/Di/DiRegistrationMap.cs @@ -0,0 +1,305 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DesignPatterns.Analyzers.Di; + +/// +/// Type→lifetime map built from explicit container registrations +/// (MSDI Add*/TryAdd, Autofac RegisterType/Register). +/// +internal sealed class DiRegistrationMap +{ + private DiRegistrationMap( + IReadOnlyList entries, + IReadOnlyDictionary lifetimes) + { + Entries = entries; + Lifetimes = lifetimes; + } + + public IReadOnlyList Entries { get; } + + /// + /// Last registration wins when the same implementation type appears more than once. + /// + public IReadOnlyDictionary Lifetimes { get; } + + /// + /// Walks all invocation expressions in and + /// collects explicit container registrations into a map. + /// + public static DiRegistrationMap Build(Compilation compilation) + { + var builder = new DiRegistrationMapBuilder(); + foreach (var tree in compilation.SyntaxTrees) + { + var model = compilation.GetSemanticModel(tree); + foreach (var invocation in tree.GetRoot().DescendantNodes().OfType()) + { + builder.TryCollect(invocation, model); + } + } + + return builder.Build(); + } + + internal static DiRegistrationMap FromEntries(IEnumerable entries) + { + var list = entries.ToImmutableArray(); + var lifetimes = new Dictionary(SymbolEqualityComparer.Default); + foreach (var entry in list) + { + lifetimes[entry.ImplementationType] = entry.Lifetime; + } + + return new DiRegistrationMap(list, lifetimes); + } +} + +/// +/// Incrementally collects explicit container registrations for +/// . +/// +internal sealed class DiRegistrationMapBuilder +{ + private readonly List _entries = new(); + + /// + /// Attempts to collect an explicit MSDI or Autofac registration from + /// . Returns when an + /// entry was added. Does not handle attributed RegisterDi expansion. + /// + public bool TryCollect(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return false; + } + + var methodName = memberAccess.Name.Identifier.ValueText; + var before = _entries.Count; + + if (methodName is "AddSingleton" or "AddScoped" or "AddTransient") + { + CollectDirectRegistration(invocation, methodName, semanticModel); + } + else if (methodName == "TryAdd") + { + CollectTryAddRegistration(invocation, semanticModel); + } + else if (methodName == "RegisterType") + { + CollectAutofacRegisterType(invocation, semanticModel); + } + else if (methodName == "Register") + { + CollectAutofacRegisterDelegate(invocation, semanticModel); + } + + return _entries.Count > before; + } + + public DiRegistrationMap Build() => DiRegistrationMap.FromEntries(_entries); + + private void CollectDirectRegistration( + InvocationExpressionSyntax invocation, + string methodName, + SemanticModel semanticModel) + { + var lifetime = methodName switch + { + "AddSingleton" => Lifetime.Singleton, + "AddScoped" => Lifetime.Scoped, + "AddTransient" => Lifetime.Transient, + _ => Lifetime.Transient, + }; + + var methodSymbol = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (methodSymbol is null) + { + return; + } + + INamedTypeSymbol? implType = null; + var args = invocation.ArgumentList?.Arguments ?? default; + + if (methodSymbol.TypeArguments.Length == 2 && + methodSymbol.TypeArguments[1] is INamedTypeSymbol implFromGeneric) + { + implType = implFromGeneric; + } + else if (methodSymbol.TypeArguments.Length == 1 && + methodSymbol.TypeArguments[0] is INamedTypeSymbol singleGeneric) + { + if (args.Count > 0 && IsFactoryOrInstanceArg(args[0], semanticModel)) + { + if (AutofacRegistration.IsOpenGeneric(singleGeneric)) + { + return; + } + + _entries.Add(new DiRegistration( + singleGeneric, + lifetime, + invocation, + skipConstructorAnalysis: true)); + return; + } + + implType = singleGeneric; + } + + if (implType is null || AutofacRegistration.IsOpenGeneric(implType)) + { + return; + } + + _entries.Add(new DiRegistration(implType, lifetime, invocation)); + } + + private void CollectTryAddRegistration( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + var argList = invocation.ArgumentList; + if (argList is null || argList.Arguments.Count == 0) + { + return; + } + + var firstArg = argList.Arguments[0].Expression; + if (firstArg is not ObjectCreationExpressionSyntax objectCreation) + { + return; + } + + var descriptorArgList = objectCreation.ArgumentList; + if (descriptorArgList is null || descriptorArgList.Arguments.Count < 3) + { + return; + } + + var descriptorArgs = descriptorArgList.Arguments; + var implType = ResolveTypeofArg(descriptorArgs[1].Expression, semanticModel); + if (implType is null) + { + return; + } + + var lifetime = LifetimeResolution.TryResolve(descriptorArgs[2].Expression, semanticModel); + if (lifetime is null) + { + return; + } + + _entries.Add(new DiRegistration(implType, lifetime.Value, invocation)); + } + + private void CollectAutofacRegisterType( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + var methodSymbol = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (!AutofacRegistration.IsAutofacMethod(methodSymbol)) + { + return; + } + + INamedTypeSymbol? implType = null; + if (methodSymbol!.TypeArguments.Length == 1 && + methodSymbol.TypeArguments[0] is INamedTypeSymbol generic) + { + implType = generic; + } + else + { + var args = invocation.ArgumentList?.Arguments ?? default; + if (args.Count == 1) + { + implType = ResolveTypeofArg(args[0].Expression, semanticModel); + } + } + + if (implType is null || AutofacRegistration.IsOpenGeneric(implType)) + { + return; + } + + _entries.Add(new DiRegistration( + implType, + AutofacRegistration.ResolveChainLifetime(invocation), + invocation)); + } + + private void CollectAutofacRegisterDelegate( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + var methodSymbol = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (!AutofacRegistration.IsAutofacMethod(methodSymbol)) + { + return; + } + + if (methodSymbol!.TypeArguments.Length != 1 || + methodSymbol.TypeArguments[0] is not INamedTypeSymbol serviceType || + AutofacRegistration.IsOpenGeneric(serviceType)) + { + return; + } + + var args = invocation.ArgumentList?.Arguments ?? default; + if (args.Count == 0 || args[0].Expression is not AnonymousFunctionExpressionSyntax) + { + return; + } + + _entries.Add(new DiRegistration( + serviceType, + AutofacRegistration.ResolveChainLifetime(invocation), + invocation, + skipConstructorAnalysis: true)); + } + + private static INamedTypeSymbol? ResolveTypeofArg( + ExpressionSyntax expr, + SemanticModel semanticModel) + { + if (expr is not TypeOfExpressionSyntax typeofExpr) + { + return null; + } + + var typeInfo = semanticModel.GetTypeInfo(typeofExpr.Type); + return typeInfo.Type as INamedTypeSymbol; + } + + private static bool IsFactoryOrInstanceArg( + ArgumentSyntax arg, + SemanticModel semanticModel) + { + var expr = arg.Expression; + + if (expr is SimpleLambdaExpressionSyntax or ParenthesizedLambdaExpressionSyntax) + { + return true; + } + + var typeInfo = semanticModel.GetTypeInfo(expr); + if (typeInfo.Type is not null && + typeInfo.Type.TypeKind == TypeKind.Delegate) + { + return true; + } + + if (expr is ObjectCreationExpressionSyntax or InvocationExpressionSyntax) + { + return true; + } + + return false; + } +} diff --git a/tests/DesignPatterns.Analyzers.Tests/DiRegistrationMapTests.cs b/tests/DesignPatterns.Analyzers.Tests/DiRegistrationMapTests.cs new file mode 100644 index 0000000..e33a5e6 --- /dev/null +++ b/tests/DesignPatterns.Analyzers.Tests/DiRegistrationMapTests.cs @@ -0,0 +1,281 @@ +using System.Collections.Generic; +using System.Linq; +using DesignPatterns.Analyzers.Di; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.DependencyInjection; + +namespace DesignPatterns.Analyzers.Tests; + +public sealed class DiRegistrationMapTests +{ + [Fact] + public void Build_maps_msdi_add_methods_by_lifetime() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + class SingletonService { } + class ScopedService { } + class TransientService { } + + static class Startup + { + public static void Configure(IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddTransient(); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Singleton, GetLifetime(map, "SingletonService")); + Assert.Equal(Lifetime.Scoped, GetLifetime(map, "ScopedService")); + Assert.Equal(Lifetime.Transient, GetLifetime(map, "TransientService")); + } + + [Fact] + public void Build_maps_msdi_two_type_args_to_implementation_type() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + interface IService { } + class ServiceImpl : IService { } + + static class Startup + { + public static void Configure(IServiceCollection services) + { + services.AddSingleton(); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Singleton, GetLifetime(map, "ServiceImpl")); + Assert.DoesNotContain(map.Lifetimes.Keys, t => t.Name == "IService"); + } + + [Fact] + public void Build_maps_tryadd_service_descriptor() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + class MyService { } + + static class Startup + { + public static void Configure(IServiceCollection services) + { + services.TryAdd(new ServiceDescriptor(typeof(MyService), typeof(MyService), ServiceLifetime.Scoped)); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Scoped, GetLifetime(map, "MyService")); + } + + [Fact] + public void Build_maps_factory_registration_with_skip_constructor_analysis() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + class ScopedService { } + + static class Startup + { + public static void Configure(IServiceCollection services) + { + services.AddScoped(_ => new ScopedService()); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Scoped, GetLifetime(map, "ScopedService")); + var entry = Assert.Single(map.Entries); + Assert.True(entry.SkipConstructorAnalysis); + } + + [Fact] + public void Build_maps_autofac_register_type_fluent_lifetimes() + { + const string source = """ + using Autofac; + + class SingletonService { } + class ScopedService { } + class TransientService { } + + static class Startup + { + public static void Configure(ContainerBuilder builder) + { + builder.RegisterType().SingleInstance(); + builder.RegisterType().InstancePerLifetimeScope(); + builder.RegisterType(); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Singleton, GetLifetime(map, "SingletonService")); + Assert.Equal(Lifetime.Scoped, GetLifetime(map, "ScopedService")); + Assert.Equal(Lifetime.Transient, GetLifetime(map, "TransientService")); + } + + [Fact] + public void Build_maps_autofac_register_type_typeof_argument() + { + const string source = """ + using System; + using Autofac; + + class ScopedService { } + + static class Startup + { + public static void Configure(ContainerBuilder builder) + { + builder.RegisterType(typeof(ScopedService)).InstancePerLifetimeScope(); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Scoped, GetLifetime(map, "ScopedService")); + } + + [Fact] + public void Build_maps_autofac_register_delegate_with_skip_constructor_analysis() + { + const string source = """ + using Autofac; + + class ScopedService { } + + static class Startup + { + public static void Configure(ContainerBuilder builder) + { + builder.Register(c => new ScopedService()).InstancePerLifetimeScope(); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Scoped, GetLifetime(map, "ScopedService")); + var entry = Assert.Single(map.Entries); + Assert.True(entry.SkipConstructorAnalysis); + } + + [Fact] + public void Build_last_registration_wins_for_same_type() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + class MyService { } + + static class Startup + { + public static void Configure(IServiceCollection services) + { + services.AddSingleton(); + services.AddTransient(); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Transient, GetLifetime(map, "MyService")); + Assert.Equal(2, map.Entries.Count); + } + + [Fact] + public void Build_ignores_register_di_calls() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + class MyService { } + + static class StrategyRegistryHolder + { + public static IServiceCollection RegisterDi( + IServiceCollection services, + ServiceLifetime implementationLifetime = ServiceLifetime.Singleton, + ServiceLifetime registryLifetime = ServiceLifetime.Singleton) + => services; + } + + static class Startup + { + public static void Configure(IServiceCollection services) + { + services.AddScoped(); + StrategyRegistryHolder.RegisterDi(services); + } + } + """; + + var map = DiRegistrationMap.Build(CreateCompilation(source)); + + Assert.Equal(Lifetime.Scoped, GetLifetime(map, "MyService")); + Assert.Single(map.Entries); + } + + private static Lifetime GetLifetime(DiRegistrationMap map, string typeName) + { + var type = map.Lifetimes.Keys.Single(t => t.Name == typeName); + return map.Lifetimes[type]; + } + + private static CSharpCompilation CreateCompilation(string source) + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + var tree = CSharpSyntaxTree.ParseText(source, parseOptions, path: "Test.cs"); + var references = new List + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ServiceLifetime).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ServiceCollection).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Autofac.ContainerBuilder).Assembly.Location), + }; + + var trustedPlatformAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (!string.IsNullOrEmpty(trustedPlatformAssemblies)) + { + foreach (var assemblyPath in trustedPlatformAssemblies.Split(Path.PathSeparator)) + { + if (assemblyPath.EndsWith("System.Runtime.dll", StringComparison.OrdinalIgnoreCase) || + assemblyPath.EndsWith("netstandard.dll", StringComparison.OrdinalIgnoreCase) || + assemblyPath.EndsWith("System.Collections.dll", StringComparison.OrdinalIgnoreCase) || + assemblyPath.EndsWith($"{Path.DirectorySeparatorChar}System.ComponentModel.dll", StringComparison.OrdinalIgnoreCase)) + { + references.Add(MetadataReference.CreateFromFile(assemblyPath)); + } + } + } + + return CSharpCompilation.Create( + "DiRegistrationMapTests", + new[] { tree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +}